Zod Is Your Friend

Ethan Glover

6,094,807 weekly NPM downloads. Over 3,000 dependent libraries. Built in support with most React form libraries, QwikJS, tRPC, and a healthy amount of funding. What is the value of Zod? TypeScript itself can create a strong level of type safety. But it doesn't protect you from runtime type bugs. If it isn't used properly, it can provide effectively zero type safety on the edges of your application. Misused, TypeScript will do more harm than good.

For example, to get the number of downloads for Zod I make a fetch call to the NPM registry:

1const zodDownloadsResponse = await fetch(
2  'https://api.npmjs.org/downloads/point/last-week/zod',

To get the response data you need to parse the JSON from the Response object.

1const zodDownloadsData = await zodDownloadsResponse.json()

There should be a glaring issue with this. zodDownloadsData is now implicitly typed as ‘any’. People new to TypeScript might solve this problem by casting it to a type.

1type ZodDownloads = {
2  downloads: number
3  end: string
4  package: string
5  start: string
8const zodDownloadsData = await zodDownloadsResponse.json() as ZodDownloads;

This is a terrible mistake. It gives the developer the illusion of type safety and doesn't make use of TypeScript at all. This is effectively the same as using JSDocs comments. It provides no type safety, it is only for in-editor documentation. The point of TypeScript is not to create handy autocompletes that reassure you everything is OK, it is to help you create a type safe application.

Shotgun parsing is a programming antipattern whereby parsing and input-validating code is mixed with and spread across processing code—throwing a cloud of checks at the input, and hoping, without any systematic justification, that one or another would catch all the “bad” cases.

- Parse, don’t validate


Zod provides a functional approach to parsing data without being purist or dogmatic about functional programming. Never assume you're getting the correct data from an external system. Check it first.

1const zodDownloadsSchema = z.object({
2  downloads: z.number(),
3  end: z.string(),
4  package: z.string(),
5  start: z.string(),
8// You can also leave out unused values
9const zodDownloadsSchema = z.object({
10  downloads: z.number(),
13// With try/catch
14try {
15  const zodDownloadsData = zodDownloadsSchema.parse(
16    await zodDownloadsResponse.json(),
17  );
18} catch (error: unknown) {
19  if (error instanceof ZodError) {
20    // Handle ZodError
21  }
24// With functional error handling
25const zodDownloadsData = zodDownloadsSchema.safeParse(
26  await zodDownloadsResponse.json(),
29if (zodDownloadsData.success) {
30  // Success case
31  // zodDownloadsData.data is defined
32  // zodDownloadsData.error is undefined
33} else {
34  // Error case
35  // zodDownloadsData.error is defined
36  // zodDownloadsData.data is undefined

Inputs and Outputs

Zod goes beyond simple types because it is making runtime checks, we can add any parsing logic we want. For example I have a “createBlog” function:

1const createBlog = (
2  properties: Readonly<z.input<typeof createBlogSchema>>,
3): z.output<typeof createBlogSchema> => {
4  return createBlogSchema.parse(properties);

First thing, I'm letting this function throw. Mostly because it runs for every blog at build time. I'm OK with letting the build fail on error. But also notice the ‘z. input’ and ‘z. output’ for the parameter and return types. The schema looks like this:

1export const createBlogSchema = z
2  .object({
3    authors: z.array(z.object({ name: z.string() })).default(DEFAULT_AUTHORS),
4    featuredImage: z.object({ description: z.string(), url: z.string() }),
5    slug: z.string(),
6    timezone: z.string().default('America/Chicago'),
7    title: z.string(),
8    updatedAt: z.string(),
9  })
10  .transform(schema => {
11    return {
12      ...schema,
13      _id: v4(),
14    };
15  });

The defaults and transformations help create two different types. The input types equivalent looks like this:

1type CreateBlogInput = {
2  authors?: Array<{ name: string }> | undefined;
3  featuredImage: { description: string; url: string };
4  slug: string;
5  timezone?: string | undefined;
6  title: string;
7  updatedAt: string;

Notice there is no _id, and authors and timezone are both optional due to the fact that they have default values. The equivalent output type will look like this:

1type CreateBlogOutput = {
2  _id: string;
3  authors: Array<{ name: string }>;
4  featuredImage: { description: string; url: string };
5  slug: string;
6  timezone: string;
7  title: string;
8  updatedAt: string;

Now _id is included and all fields are required. Two types for the price of one! We know exactly what we can pass in and exactly what we'll get back. And we know this for sure because of the runtime parsing.

Of course as a last note, the triviality of this function makes getting both input and output types from a single Zod schema possible. This is basically treating Zod as a class constructor. Which is entirely valid. But maybe not the usual use case.


React Hook Formis just one example of a form library with built in support for validation. This provides an easy way to create complex validations for forms without lengthy if/else conditions and checks within a submit function.

A sign up form may use a schema like this:

1const nameSchema = z.string().min(3).max(100);
3export const exampleSignUpSchema = z
4  object({
5    confirmPassword: z.string(),
6    email: z.string().email('Invalid email address'),
7    firstName: nameSchema,
8    lastName: nameSchema,
9    password: z
10      .string()
11      .min(8, 'Password must be at least 8 characters long')
12      .max(30, 'Password cannot be longer than 30 characters')
13      .regex(
14        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!#$%&*@^])/,
15        'Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character (!@#$%^&*)',
16      ),
17  })
18  .refine(
19    data => {
20      return data.password === data.confirmPassword;
21    },
22    {
23      message: 'Passwords do not match.',
24      path: ['confirmPassword'],
25    },
26  );

This gives us a pretty straightforward and quickly readable definition of this forms rules. names need to be between 3 and 100 characters. We have built-in email validation. A regex test for the password. And a final refine to make sure the password and confirmPassword are equal.

When using react hook form, all you have to do is include the ZodSchema as a “resolver” and it will run before the form onSubmit.

1const { formState: { errors } } = useForm({
2  resolver: zodResolver(exampleSignUpSchema),

Go ahead and give it a try.


tRPC makes beautiful use of Zod by baking it into the framework itself. I used tRPC and Zod as a great example of how to separate parsing from API logic for testability. For the tl;dr, tRPC provides a clean interface to parse and validate all incoming and outgoing data from your API:

1export const create = procedure
2  .input(projectPreferenceCreateInputSchema)
3  .output(projectPreferenceCreateReturnSchema)
4  .mutation(async ({ input }): Promise<ProjectPreferenceCreateReturn> => {
5    return prisma.projectPreference.create({
6      select: {
7        id: true,
8        name: true,
9      },
10      data: {
11        name: input.name,
12        user: {
13        connect: {
14          username: input.username,
15        },
16      },
17    },
18  });

But of course because Zod is a standalone library it works fine anywhere with TypeScript. Just create a schema and parse it. Handle errors with the previously mentioned try/catch or functional safeParse.


Again, Zod as a tool for testing is largely covered in the previously mentioned “Making React Testable” . With @anatine/zod-mock it's very easy to mock data from Zod schemas. Using faker.js as a dependency this library will do it automatically. Using tools like Zod and it's supporting libraries will quickly help you find a path towards better unit testing. Many developers struggle to really understand separation of concerns. Doing “stream of consciousness coding” creates bad tests whether you prescribe to any tribe like TDD or BDD or none at all. These frameworks won't help you if you don't split up your code.

Learning to separate parsing from logic means smaller, more concise, and more accurate tests. With each piece easier to test in full. Schema can be tested with correct error handling and logic can be tested given variations of that schemas parsed output. Mixing parsing and logic eventually leads to missed tests due to the fact that the code ran and we long longer see the misstep in coverage reports.

1it('should validate correct input', () => {
2  const input = generateMock(projectPreferenceCreateInputSchema);
4  const parsed = projectPreferenceCreateInputSchema.parse(input);
6  expect(parsed).toStrictEqual(input);
9it('should throw on incorrect input', () => {
10  const input = {
11    username: true,
12    name: 546,
13  };
14  let errors: ZodError | null = null;
16  try {
17    projectPreferenceCreateInputSchema.parse(input);
18  } catch (zError) {
19    if (zError instanceof ZodError) {
20      errors = zError;
21    }
22  }
24  expect(errors?.issues[0].message).toBe('Expected string, received boolean');
25  expect(errors?.issues[1].message).toBe('Expected string, received number');

Environment Variables

As a bonus, and a testament to the flexibility of Zod. For every project, I always include a .environment.ts file which reads process.env. If for example, you have a OPENAI_KEY variable, the type of process.env.OPENAI_KEY is ‘string | undefined’. If all of your environment variables are defined, this isn't true. It should be ‘string’. Nor does this help you determine what is and isn't defined when it is defined. In other words, process.env has to be shotgun parsed. So we can simply parse it with Zod.

1const environmentSchema = z.object({
2  OPENAI_KEY: z.string(),
3  DB_CONNECTION: z.string().default('sqlite://...'),
6export default environmentSchema.parse(process.env);
8environment.OPENAI_KEY // Type is string, not string | undefined!

This conveniently forces environment variables to be checked when the app is started or during build. If something is missing, it throws and we get a helpful reminder to make sure to define all required environment variables.