15 June, 2023 | 7 min read

A Beginner's Introduction to Zod

Before we began migrating our project to TypeScript, there was a strange function that was used all over the codebase.

function mandatory(parameterName = '') {
  throw new ReferenceError(`Missing parameter ${parameterName}`);
}

It was used as a run-time check for any parameter that needed to be defined. If the value was undefined, mandatory would be called to get the default value and an error would be thrown.

function add3(num = mandatory('num')) {
  return num + 3;
}

add3(); // Error: Missing parameter num

The idea was to error early. When we make incorrect assumptions about the data in our app, we can end up with subtle bugs that are hard to debug. Failing when something actually goes wrong makes it a lot easier to figure out what the problem is.

Lying In TypeScript

This function has now been replaced with TypeScript, which gives us build time checking instead. But how much trust should we put in TypeScript?

In a closed codebase, we can have perfect type confidence. However, non-trivial codebases are never closed and there are places in our app where we have less confidence. These include:

  • API responses
  • Local/session storage
  • Forms
  • Environment variables
  • Query strings

In these places, we need to make assumptions about what the type will be and TypeScript will assume our type is correct.

I have heard this referred to as ”Lying in TypeScript”. We can lie to TypeScript because our assumptions can be wrong.

  • The API may change its response
  • Someone may manually change the query strings or session storage
  • We may put a typo in an environment variable or forget to set it up

These incorrect assumptions may not cause problems at first and can pass through our codebase. We may not notice anything until we start seeing strange bugs or errors in other parts of the code. Have fun debugging that!

The solution is the same as the start of the post: fail early.

That’s where Zod comes in.

Basic Zod Example

Zod provides run-time type checking to increase the confidence of your types.

import { z } from 'zod';

// 1
const numberParser = z.number();

// 2
const unknownVariable: unknown = 3;

// 3
const safeVariable = numberParser.parse(unknownVariable);
  1. We import z from Zod and create a number parser. These are usually called schemas and that’s what we’ll call them in future examples. But numberParser helps us better understand what it’s doing in this example.

  2. We then have an unknown variable. Although we know that unknownVariable is a number, it represents a value that is either literally typed as unknown (such as the parameter in a catch statement) or a value that we have less confidence in.

  3. Finally, we run numberParser.parse(unknownVariable). This checks at run-time whether our unknown variable is a number and throws an error if it isn’t. Because we know it will have thrown an error if unknownVariable is not a number, safeVariable will be correctly typed as number.

Throwing an error may seem radical, but it sticks to the fail early principle. It catches problems as soon as our assumptions are incorrect instead of letting them propagate through codebase.

Zod and Object Schemas

Let’s take a look at a more typical use case of Zod: validating endpoint requests. We’ll create a function that fetches Pokemon data from the PokéAPI.

const getPokemon = async (pokemon: string) => {
  const data = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`).then(
    (res) => res.json()
  );
  
  return data;
}

TypeScript has no clue what the shape of the response will be, so it just leaves data as any. So let’s add a type. PokéAPI sends us a lot of data but all we care about is the ID, name, type and default image.

interface Pokemon {
  id: number;
  name: string;
  types: Array<{
    slot: number;
    type: {
      name: string;
    };
  }>;
  sprites: {
    front_default: string;
  };
}

const getPokemon = async (pokemon: string) => {
  const data = (await fetch(
    `https://pokeapi.co/api/v2/pokemon/${pokemon}`
  ).then((res) => res.json())) as Pokemon;

  return data;
};

This is lying in TypeScript. We explicitly tell TypeScript the type of the response and TypeScript will take our word for it.

Me: Do you trust me? TypeScript: With every cell of my body.

Let’s see how we can add some runtime validation using Zod.

import { z } from 'zod';

const pokemonSchema = z.object({
  id: z.number(),
  name: z.string(),
  types: z.array(z.object({
    slot: z.number(),
    type: z.object({
      name: z.string(),
    }),
  })),
  sprites: z.object({
    front_default: z.string(),
  }),
});

const getPokemon = async (pokemon: string) => {
  const data = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`).then(
    (res) => res.json()
  );

  const validatedData = pokemonSchema.parse(data);
  
  return validatedData;
}

The Zod schema mirrors the interface shape but is now made of Zod primitives.

Now when we fetch our data, we can parse it through the schema. If the shape of the response doesn’t match our expected type, Zod will immediately throw an error before our invalid assumptions can spread. Otherwise, we’ll get validatedData which will have the correct type.

As an added bonus, all the unrecognised keys that come in the response will be stripped away, so our object will match perfectly our type.

Zod Is The Source Of Truth

Some of you may be seeing a potential issue with this approach. What happens when we need to use the Pokemon type somewhere else in the code? We’ll have our type and our schema that need to match each other. It’ll be too easy for one to be updated without the other and for them to stop being in sync. Surely that will just give us a false sense of confidence in our types?

Zod has the answer to that. It allows us to infer the type definitions from our schemas.

import { z } from 'zod';

const pokemonSchema = z.object({
  id: z.number(),
  name: z.string(),
  types: z.array(z.object({
    slot: z.number(),
    type: z.object({
      name: z.string(),
    }),
  })),
  sprites: z.object({
    front_default: z.string(),
  }),
});

type Pokemon = z.infer<typeof pokemonSchema>

The Zod schema now becomes the single source of truth.

Transforming Data

So now we have our schema and our TypeScript types are flowing from it. Everything is looking great.

Except…

Our Pokemon object is way more complicated than it needs to be. It might make sense for PokéAPI to send the data in this format, but now that it’s here, it would be nice if we could transform it into a simpler, flatter format.

Well, Zod makes that easy! It provides .transform function on schemas which allows you to transform the data after parsing.

const helloString = z.string().transform((val) => `Hello, ${val}`);

helloString.parse('Brock'); // => 'Hello, Brock' 

const stringToNumber = z.string().transform((val) => Number(val));

stringToNumber.parse('3'); // => 3

First, let’s pull out the Pokemon types schema into its own variable. Then we’ll transform it so that instead of an array of objects, we’ll have an array of the names of the Pokemon types.

const pokemonTypesSchema = z.array(
  z.object({
    slot: z.number(),
    type: z.object({
      name: z.string(),
    }),
  })
  .transform((val) => val.type.name)
);

// [{ slot: 1, type: { name: 'fire' } }, { slot: 2, type: { name: 'flying' } }]
// ↓ ↓ ↓ ↓ transforms to ↓ ↓ ↓ ↓
// ['fire', 'flying']

Then we can transform our pokemonSchema to flatten the sprites value.

const pokemonSchema = z
  .object({
    id: z.number(),
    name: z.string(),
    types: PokemonTypesSchema,
    sprites: z.object({
      front_default: z.string(),
    }),
  })
  .transform((val) => ({
    id: val.id,
    name: val.name,
    types: val.types,
    sprite: val.sprites.front_default,
  }));

And there we have it, our complex schema has been transformed into a much simpler object and our Pokemon type automatically picks up this new shape.

Just The Beginning

We’ve only scratched the surface of the utilities that Zod provides.

It allows you to set the default value if there is an error.

It can be more specific with it’s validation, such as validating a string is a valid email or that a number is greater than a maximum value.

Matt Pocock has even shown how Zod can be combined with generics to create extremely powerful abstractions.

Zod is the perfect companion to TypeScript.


Follow me on Twitter or Bluesky to get updates on new posts.