Zod is amazing. Here’s why we're also using TypeBox

Tom MacWright on

I’ve written a bit about how much I like Zod. I’ve also contributed a bunch of improvements to Zod, especially around performance.

Zod has revolutionized how developers think about data validation because, I’d say, it has a wonderful API. Creating a shape for some data in Zod is really developer-friendly:

import { z } from "zod";
const User = z.object({
username: z.string(),
});
User.parse({ username: "Ludwig" });
// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }

However, for Val Town’s recent transition from express to Fastify, we’ve been using TypeBox in place of Zod for validation. Fastify can support either Zod or TypeBox via a Type Provider system, but TypeBox was the clear option for us.

Why? Mostly because TypeBox plays well with JSON Schema, and we need JSON Schema definitions for our OpenAPI specification. Plus, it’s pretty fast.

TypeBox ↔ JSON Schema ↔ OpenAPI

As we’ve been migrating to Fastify for our API server, one of our major goals has been to strictly define all of the inputs and outputs, and to be able to express them all in an OpenAPI specification that will have a lot of trickle-down benefits.

OpenAPI 3.1.0 is 100% compatible with JSON Schema – the types of query parameters, request bodies, response bodies, and more are all defined with JSON Schema objects.

Here’s an example of one of our simplest routes - the esm.town endpoint that exposes Val contents as TypeScript or JavaScript modules so that they can be run in Deno or browsers:

fastify.route({
method: "GET",
url: "/v/:handle/:name",
schema: {
description: "Get source code for a val",
// TypeBox provides this Type variable
// which we use to build the schema
querystring: Type.Object({
v: Type.Optional(Type.Integer({ minimum: 0 })),
}),
params: Type.Object({
handle: Type.String(),
name: Type.String(),
}),
},
// …
});

Let’s look at the querystring type (and here’s a Val that shows doing just this):

const querystring = Type.Object({
v: Type.Optional(Type.Integer({ minimum: 0 })),
});
console.log(JSON.stringify(querystring));
// {"type":"object","properties":{"v":{"minimum":0,"type":"integer"}}}

TypeBox objects are JSON Schema objects with extra in-memory magic!

A fun way to think about TypeBox is that it does for JSON Schema what drizzle does for SQL - it’s a nice, TypeScript-friendly builder syntax for an existing spec that tries to expose all of the spec’s power. That’s not all TypeBox is - it has an extremely fast built-in type checker, it can create example values based on schemas, and a lot more.

There are ways to use Zod to produce JSON Schema. For example, there’s zod-to-json-schema:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const mySchema = z
.object({
myString: z.string().min(5),
myUnion: z.union([z.number(), z.boolean()]),
})
.describe("My neat object schema");
const jsonSchema = zodToJsonSchema(mySchema, "mySchema");

That said, TypeBox’s supported validators mirror what you can do in JSON Schema precisely, whereas Zod’s often stretch into things that JSON Schema can’t do, for two reasons:

  • Zod is most often used without reference to JSON Schema, so there’s no sense in it trying to be a subset of JSON Schema.
  • Zod follows the parse, don’t validate philosophy.

Parse, don’t validate

Alexis King coined this snappy slogan and popularized it in this great blog post, “Parse, don’t validate”. Here’s a vastly oversimplified example of how these two approaches might look in TypeScript:

// A validation-based approach
if (isValid(object)) {
// This is valid
console.log(object);
}
// A parsing-based approach
const { error, value } = parse(object);
if (!error) {
// This is valid
console.log(value);
}

Validating is the default approach of a module like ajv and the behavior of how TypeBox’s Check method works: they do some form of type narrowing to let the type system know that the given data is valid, but they do not copy or mutate the data.

Zod, on the other hand, parses, and it creates a deep clone of its input data:

const stringSchema = z.string();
stringSchema.safeParse("billie"); // => { success: true; data: 'billie' }

This, in most cases, is awesome. The flexibility of Zod lets you do a lot of useful work in the ‘parsing’ step. You can transform inputs and strip extra keys on objects. Zod can guarantee that that your data is ‘clean’ - it has the expected keys, but doesn’t have any extra keys. Most of the time, this is a thing about Zod that I greatly appreciate.

However, for validating the types of our Fastify schema, this approach has two downsides:

  • All of the methods in Zod that rely on the “parse, don’t validate” architecture won’t be convertible to JSON Schema. JSON Schema doesn’t let you transform data, or pick object properties: it’s a system for validation.
  • Parsing and deep-copying inputs is one of the main reasons why Zod’s performance lags other type validators. It is harder to optimize its approach than it is to optimize an approach that doesn’t require copying data.

Performance

Now that I’ve mentioned performance, I should reiterate something I’ve written before a bunch of times: performance wasn’t the main reason for using TypeBox instead of Zod for this problem. Performance also wasn’t the main reason for switching to Fastify from express.

And this applies even though fastify is benchmarked faster than express and TypeBox is benchmarked faster than Zod.

I am a strong believer in Carlos Bueno’s mature optimization handbook, which dictates that:

The trickiest part of speeding up a program is not doing it, but deciding whether it’s worth doing at all.

And, later:

Before you can optimize anything you need a way to measure what you are optimizing. Otherwise you are just shooting in the dark. Before you can measure you need a clear, explicit statement of the problem you are trying to solve. Otherwise, in a very real sense, you don’t know what you are doing.

So, I keep in mind that everything in the application that is not the bottleneck, is not the bottleneck. I know in my mind that there’s a performance difference between different kinds of loops in JavaScript, but in almost every case that doesn’t matter. The overhead of a .forEach call versus a for loop is going to be insignificant versus a single database call or network request.

And in the vast majority of applications, validation and parsing is not the bottleneck. That’s the case in Val Town. Our bottlenecks are things like network topology, cold-start times for processes, database access, and disk access. We have metrics in Honeycomb that break down the time cost of running vals, and Fastify overhead isn’t a major contributor.

Take it from the author of Arktype, one of the extremely fast Zod alternatives:

I’ll tell you a secret- validator performance is mostly negligible in ~95% of situations and Zod’s is totally reasonable.

Now, I do write this as someone who spent a bunch of time writing those performance-oriented Zod pull requests, which contributed to a pretty decent performance improvement on some benchmarks. Why?

Because at the time, I was working on Placemark, a geospatial data-editing tool, and that tool was using Zod to validate geospatial data. Geospatial data is large, often in the hundreds of megabytes to gigabytes and more. And it contains a lot of nested JSON structures, like huge arrays of longitude, latitude positions that describe the precise curves of a line.

So on that project I genuinely used the Chrome Profiling tools and got a result in which Zod was a big part of the total time spent, so it became a high priority to optimize it.

See also

There is a lot of competition happening in the TypeScript validation space right now, which is super exciting for me, free-riding on all this innovation. So forgive me for not comprehensively covering all of the projects.

Valibot is cool because it be a much smaller addition to your JavaScript bundle than Zod.

Arktype is extremely cool because it essentially does for the TypeScript type system what TypeBox does for JSON Schema: the Arktype idea is that you don’t need to learn a new type syntax - you can just write TypeScript-style type syntax and it’ll get compiled to type checkers that can run at runtime. TypeBox has something similar in its codegen module.

Not to mention, Colin just got funding to build the next version of Zod, v4, which will undoubtedly be a major step up for performance and power. The future is bright!

Edit this page