Zod Quick Tutorial

Cover Image for Zod Quick Tutorial
Rahul M. Juliato
Rahul M. Juliato
#zod#validator# typescript

Validation is a crucial aspect of any application, ensuring that the data it operates on meets the necessary criteria. However, implementing robust validation can often be complex and time-consuming. Enter ZOD, the lightweight and versatile validation library that simplifies this process without compromising on functionality.

Intro

According to https://zod.dev/?id=introduction, Zod is a TypeScript-first schema declaration and validation library, where the term "schema" to broadly refer to any data type, from a simple string to a complex nested object.

Some great aspects of Zod:

  • Zero dependencies

  • Works in Node.js, Bun and Deno and all modern browsers

  • Tiny: 8kb minified + zipped

  • Immutable: methods (e.g. .optional()) return a new instance

  • Concise, chainable interface

  • Functional approach: parse, don't validate

  • Works with plain JavaScript too! You don't need to use TypeScript.

Initializing our playground

To initialize our playground project we will start by creating a generic typescript project using Vite https://zod.dev/?id=introduction.

pnpm create vite

We can choose vanilla, typescript, and zod-testing for our project name.

This will create the following tree:

└── zod-testing
    ├── index.html
    ├── package.json
    ├── public
    │   └── vite.svg
    ├── src
    │   ├── counter.ts
    │   ├── main.ts
    │   ├── style.css
    │   ├── typescript.svg
    │   └── vite-env.d.ts
    └── tsconfig.json

You can go ahead and delete almost all files, leaving just these:

└── zod-testing
    ├── index.html
    ├── package.json
    ├── public
    ├── src
    │   └── main.ts
    └── tsconfig.json

You may also empty the contents of main.ts.

Now, cd into zod-testing and install the dependencies:

cd zod-testing
pnpm install

Let's now create a hello world message just to see if everything is working properly. Inside src/main.ts.

console.log("Hello Zod")

And on the project root run pnpm dev.

You should see something like:

➜ pnpm dev

> zod-testing@0.0.0 dev /home/rmj/Projects/github/zod-tutorial/zod-testing
> vite



  VITE v5.1.6  ready in 100 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

Navigate to the URL provided (http://localhost:5173/ in my case), you'll see an empty page, but opening your Developer Tools Console you will get the hello world message.

We're opting on use your browser developer tools because it probably shows messages formatted in a better way than directly on the console, besides providing an running REPL instance of V8 context aware of your project.

Installing Zod

We just set a NodeJS project. If you want to use Zod with Deno or Bun, check Zod Introduction Page to check how to to it: https://zod.dev/?id=introduction.

To install zod we can just:

pnpm install zod

It might be super quick and need no external dependencies.

Basic Usage

Very very simple, you import it and start using it. Let's clean our main.ts file and add:

import {z} from "zod";

Also, please check that your tsconfig.json has the "strict" option under "compilerOptions" set to true.

Let's start by defining our first schema. Using it and calling our parser.

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
});

const user = { username: "Rahul" };

console.log(UserSchema.parse(user));

What the parse function does is basically ask if the provided data matches our schema.

We might see on the console:

{
    "username": "Rahul"
}

If we change our username to an invalid option such as:

...
	const user = { username: 1 };
...

We now get an error:

zod.js?v=72bcbb81:479 Uncaught ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "number",
    "path": [
      "username"
    ],
    "message": "Expected string, received number"
  }
]
    at get error (zod.js?v=72bcbb81:479:23)
    at _ZodObject.parse (zod.js?v=72bcbb81:578:18)
    at main.ts:9:24

And that's great, because we can now get errors based on the validation of our data.

Another cool feature from Zod that makes it very popular is how easy it is to integrate it with typescript Types.

We may have the following code:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
});

type User = {
  username: string;
};

const user: User = { username: "Rahul" };

console.log(UserSchema.parse(user));

See it is almost the same, but we defined the User type.

It's nothing wrong with it, but we defined our type twice: once in the Schema and once on the type directive.

With Zod what we can do is actually infer the type from the Schema, like:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
});

type User = z.infer<typeof UserSchema>;

const user: User = { username: "Rahul" };

console.log(UserSchema.parse(user));

The z.infer can infer the type for us and we may now maintain only the Schema, without repeating our selfs.

Another cool thing we can do is using the method safeParse, such as:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
});

const user = { username: 1 };

console.log(UserSchema.safeParse(user));

This returns:

{
    "success": false,
    "error": {
        "issues": [
            {
                "code": "invalid_type",
                "expected": "string",
                "received": "number",
                "path": [
                    "username"
                ],
                "message": "Expected string, received number"
            }
        ],
        "name": "ZodError"
    },
    "_error": {
        "issues": [
            {
                "code": "invalid_type",
                "expected": "string",
                "received": "number",
                "path": [
                    "username"
                ],
                "message": "Expected string, received number"
            }
        ],
        "name": "ZodError"
    }
}

As you can see, we won't raising any errors using safeParse, but we have an object containing the success property, and if this false, the error property can be read to show specific what went wrong during parsing.

Fixing our username to a string:

const user = { username: "Rahul" };

We now have the following result from safeParse:

{
    "success": true,
    "data": {
        "username": "Rahul"
    }
}

As you may see the success property is true and our parsed data is passed within the data property.

Primitives

Delving a bit more into Zod, let's study a bit of the primitives datatypes:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
  age: z.number(),
  birthday: z.date(),
  isProgrammer: z.boolean(),
});

const user = { username: "Rahul" };

console.log(UserSchema.safeParse(user).success);

As we can see, we can test for strings, numbers (also Bigints, and other forms), dates (several kinds), booleans and so on.

But this code actually returns false, why so?

Because inside of Zod, every single validation is required by default.

We can mark a validation as optional like this if we want to:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
  age: z.number().optional(),
  birthday: z.date().optional(),
  isProgrammer: z.boolean().optional(),
});

const user = { username: "Rahul" };

console.log(UserSchema.safeParse(user).success);

And now it returns true.

We can also use:

const UserSchema = z.object({
  username: z.string(),
  age: z.number().optional(),
  birthday: z.date().optional(),
  isProgrammer: z.boolean().optional(),
  test: z.undefined(),      // it must be undefined
  test2: z.null(),          // it must be null
  test3: z.void(),          // it must be void, like the return of a function
  test4: z.any(),           // anything is valid
  test5: z.unknown(),       // anything is valid
  test6: z.never(),         // it should never have this property
});

Basic Validations

Here we may find a basic example of how to do a validation:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string().min(3), // .max()  .url()  ....
  age: z.number().gt(0),
  birthday: z.date(),
  isProgrammer: z.boolean(),
});

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  age: 37,
  birthday: new Date(),
  isProgrammer: true,
};

console.log(UserSchema.safeParse(user).success);

This returns true.

I won't cover every single detail, but as you can see each validation may have several chainable sub validators, such as min, max, regex, and so on.

Here we extend a bit our example:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string().min(3),
  age: z.number().gt(0),
  birthday: z.date(),
  isProgrammer: z.boolean(),
  isGamer: z.boolean().nullable(), // can be null
  isMetalhead: z.boolean().nullish(), // can be null OR undefined
  isZen: z.boolean().default(true),
  luckyNumber: z.number().default(Math.random),
});

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  age: 37,
  birthday: new Date(),
  isProgrammer: true,
  isGamer: null,
  isMetalhead: undefined,
};

console.log(UserSchema.safeParse(user).success);

As we can see we now have nullable, that marks a field to be of the type OR null. We also have nullish that accepts the parsing type, null OR undefined. The default method, that actually defines a fallback value if the parsing fails. The default can also accept a function, like we did in the example with luckyNumber.

When dealing with a list of possibilities, we can use the enum method to list all possible values. Note this won't be typed as an enum by infer.

import { z } from "zod";

const UserSchema = z.object({
  username: z.string().min(3),
  age: z.number().gt(0),
  birthday: z.date(),
  isProgrammer: z.boolean(),
  isGamer: z.boolean().nullable(), // can be null
  isMetalhead: z.boolean().nullish(), // can be null OR undefined
  isZen: z.boolean().default(true),
  luckyNumber: z.number().default(Math.random),
  preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
});

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  age: 37,
  birthday: new Date(),
  isProgrammer: true,
  isGamer: null,
  isMetalhead: undefined,
  preferedHobby: "Programming",
};

console.log(UserSchema.safeParse(user).success);

This returns true and hovering over the User type we get:

type User = {
     username: string;
    age: number;
    birthday: Date;q
    isProgrammer: boolean;
    isGamer: boolean | null;
    isZen: boolean;
    luckyNumber: number;
    preferedHobby: "Programming" | "Weight Lifiting" | "Guitar";
    isMetalhead?: boolean | ... 1 more ... | undefined;
}

Object Type

Let's begin by taking a look at this code:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string().min(3),
  age: z.number().gt(0),
  birthday: z.date(),
  isProgrammer: z.boolean(),
  preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
});

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  age: 37,
  birthday: new Date(),
};

console.log(UserSchema.partial().parse(user));

What is happening here is that we now have a partial, really useful to use with forms and whenever you need partial validation.

Even tough preferedhobby is there on the schema, .partial() usage makes every field optional.

If we move the .partial() to the schema, like:

const UserSchema = z.object({
  username: z.string().min(3),
  age: z.number().gt(0),
  birthday: z.date(),
  isProgrammer: z.boolean(),
  preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
}).partial();

We can now check that the type of the UserSchema is turned on all fields optional:

const UserSchema: z.ZodObject<{
     username: z.ZodOptional<z.ZodString>;
    age: z.ZodOptional<z.ZodNumber>;
    birthday: z.ZodOptional<z.ZodDate>;
    isProgrammer: z.ZodOptional<z.ZodBoolean>;
    preferedHobby: z.ZodOptional<...>;
}, "strip", z.ZodTypeAny, {
    ...;
}, {
    ...;
}>

We can also use other things from typescript, such as .pick().

const UserSchema = z
  .object({
    username: z.string().min(3),
    age: z.number().gt(0),
    birthday: z.date(),
    isProgrammer: z.boolean(),
    preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
  })
  .pick({ username: true });

And now we only use the username validation;

type User = {
	username: string;
}

We could also use omit(), like:

const UserSchema = z
  .object({
    username: z.string().min(3),
    age: z.number().gt(0),
    birthday: z.date(),
    isProgrammer: z.boolean(),
    preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
  })
  .omit({ username: true });

And now everything BUT username is here on the type:

type User = {
    age: number;
    birthday: Date;
    isProgrammer: boolean;
    preferedHobby: "Programming" | "Weight Lifiting" | "Guitar";
}

Also, we have .deepPartial() that is the same as partial but makes objects inside of objects inside of objects... deeply nested, all partials.

Another thing you can do is "extend" an object with .extend():

const UserSchema = z
  .object({
    username: z.string().min(3),
    age: z.number().gt(0),
    birthday: z.date(),
    isProgrammer: z.boolean(),
    preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
  })
  .extend({ knowsTypescript: z.boolean() });

And if we had multiple Schemas we could merge them with:

const UserSchema = z
  .object({
    username: z.string().min(3),
    age: z.number().gt(0),
    birthday: z.date(),
    isProgrammer: z.boolean(),
    preferedHobby: z.enum(["Programming", "Weight Lifiting", "Guitar"]),
  })
  .merge(
    z.object({
      name: z.string(),
      surname: z.string(),
    }),
  );

And now for the last:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string().min(3),
});

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  alias: "rmj",
};

console.log(UserSchema.partial().parse(user));

What should happen here? We defined a key to an object that is not present on our schema.

Well, we get:

{username: 'Rahul'}

By default if allows you to pass something nothing inside the schema, but it takes the thing of the result object.

You can change this behaviour by passing .passthrough().

With:

const UserSchema = z
  .object({
    username: z.string().min(3),
  })
  .passthrough();

We now have:

{username: 'Rahul', alias: 'rmj'}

You could also add .strict() to the schema, like:

const UserSchema = z
  .object({
    username: z.string().min(3),
  })
  .strict();

This will throw an error, because the extra data is being treated as not recognized by the schema.

Array Type

Take a look at this code:

import { z } from "zod";

const UserSchema = z
  .object({
    username: z.string().min(3),
    friends: z.array(z.string()),
  })
  .strict();

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  friends: ["Amy", "Boris"],
};

console.log(UserSchema.partial().parse(user));

We are now declaring a "friends" array of strings.

The type of User is:

type User = {
     username: string;
    friends: string[];
}

We can also check the expected type by using shape, like in:

UserSchema.shape.friends.element;

Hovering over element we get:

(property) ZodArray<ZodString, "many">.element: z.ZodString 

Now we can add the .nonempty() property to arrays, like:

const UserSchema = z.object({
  username: z.string().min(3),
  friends: z.array(z.string()).nonempty(),
});

And now passing an empty array to the friends property raises an error.

We can also specify things like .min(), .max(), .length().

Tuples

Starting the advanced types, tuples are a fixed lenght array where each element has a specific type.

Take a look at this example code:

import { z } from "zod";

const UserSchema = z.object({
  username: z.string(),
  coords: z.tuple([z.number(), z.number(), z.number()]),
});

type User = z.infer<typeof UserSchema>;

const user = {
  username: "Rahul",
  coords: [1, 2, 3],
};

console.log(UserSchema.parse(user));

It returns:

{
    "username": "Rahul",
    "coords": [
        1,
        2,
        3
    ]
}

If we change the lenght or types of the provided "coords" data, we now get an error.

We can also change and combine whatever we want like:

  coords: z.tuple([z.number(), z.string(), z.number()]),
  coords: z.tuple([z.number(), z.string(), z.number().gt(4).int()]),

And we can also use .rest() to define a type for the last infinity next numbers of the array, like:

const UserSchema = z.object({
  username: z.string(),
  coords: z.tuple([z.string(), z.date()]).rest(z.number()),
});

We can now declare a user like:

const user = {
  username: "Rahul",
  coords: ["1", new Date(), 2, 3, 4, 5],
};

Union Type

Let's take a look in the code below:

import { z } from "zod";

const UserSchema = z.object({
 id: z.union([z.string(), z.number()]),
});

type User = z.infer<typeof UserSchema>;

const user = {
 id: "abcde",
};

console.log(UserSchema.parse(user));

We now have a union in our id, meaning the type could be something or other.

An alternative would be using "or" like:

const UserSchema = z.object({
  id: z.string().or(z.number()),
});

Another kind of union is a discriminated union, like:

import { z } from "zod";

const UserSchema = z.object({
  id: z.discriminatedUnion("status", [
    z.object({ status: z.literal("success"), data: z.string() }),
    z.object({ status: z.literal("failed"), error: z.instanceof(Error) }),
  ]),
});

type User = z.infer<typeof UserSchema>;

const user = {
  id: { status: "success", data: "asdf" },
};

console.log(UserSchema.safeParse(user));

This will grant you a conditional parser, testing first the status value and returning either one or another data.

In this case the snippet returns:

{
    "id": {
        "status": "success",
        "data": "asdf"
    }
}

If we change our user to:

const user = {
  id: { status: "failed", error: Error("asdfg") },
};

We get in return:

{
    "status": "failed",
    "error": {...}
}

Documentation says this is going to have performance gains. So, if performance is an issue, use a discriminatedUnion when you can.

Record Type

Now, what happens when you have like a map of users, like:

{
  "abcce-asbcde-abd": "User 1",
  "ab123-asdkjk-j2k": "User 2",
  ...
}

We can use a .record() method to define what we want inside the record:

import { z } from "zod";

const UserMap = z.record(z.string());

const users = {
  "abcce-asbcde-abd": "User 1",
  "ab123-asdkjk-j2k": "User 2",
};

console.log(UserMap.parse(users));

This returns:

{
    "abcce-asbcde-abd": "User 1",
    "ab123-asdkjk-j2k": "User 2"
}

And if we change our users to:

import { z } from "zod";

const UserMap = z.record(z.string());

const users = {
  "abcce-asbcde-abd": "User 1",
  "ab123-asdkjk-j2k": 123,
};

console.log(UserMap.parse(users));

We now get an error. This is how we parse "objects" (recors) values.

But what happens if we change our key to, let's say a number?

import { z } from "zod";

const UserMap = z.record(z.string());

const users = {
  "abcce-asbcde-abd": "User 1",

Now we get:

{
    "123": "User 2",
    "abcce-asbcde-abd": "User 1"
}

See? No errors.

If we want to define both key and value datatypes, we define our record with 2 parameters, the first is always the key and the second the value:

import { z } from "zod";

const UserMap = z.record(z.string(), z.number());

const users = {
  "abcce-asbcde-abd": 321,
  "ab123-asdkjk-j2k": 123,
};

console.log(UserMap.parse(users));

This returns:

{
    "abcce-asbcde-abd": 321,
    "ab123-asdkjk-j2k": 123
}

Map Type

Most times when you're dealing with things with set Keys and set Values types, you maybe want to use a Map instead of a Record, and Zod has support for it, example:

import { z } from "zod";

const UserMap = z.map(z.string(), z.object({ name: z.string() }));

const users = new Map([
  ["abcce-asbcde-abd", { name: "John" }],
  ["321", { name: "Mary" }],
]);

console.log(UserMap.parse(users));

That returns:

new Map([
    [
        "abcce-asbcde-abd",
        {
            "name": "John"
        }
    ],
    [
        "321",
        {
            "name": "Mary"
        }
    ]
])

Set Type

We can also work with Sets, a modified array where every value is unique, like in this example:

import { z } from "zod";

const UserMap = z.set(z.number());

const users = new Set([1, 1, 2, 3, 4, 4]);

console.log(UserMap.parse(users));

It can also use .min(), .max() and other arrays methods.

Promise Type

With Zod we can also validate Promises:

import { z } from "zod";

const PromiseSchema = z.promise(z.string());

const p = Promise.resolve("asdf");

console.log(PromiseSchema.parse(p));

That returns:

Promise {<pending>}

And if we parse another non string info inside resolve or pass something that is not a promise at all, like a string, we get the error.

A site note here, the promise validation is actually a 2 step process, meaning it validades this is a promise and than validates the promise content.

Advanced Validation

You can create your own custom validation with .refine().

This means we could do something like:

import { z } from "zod";

const BrandEmail = z
  .string()
  .email()
  .refine((val) => val.endsWith("@something.com"), {
    message: "Email should end with @something.com",
  });

const email = "test@something.com";

console.log(BrandEmail.parse(email));

Which returns:

test@something.com

If we test with some email like test@test.com we would get:

Uncaught ZodError: [
  {
    "code": "custom",
    "message": "Email should end with @something.com",
    "path": []
  }
]

You can also take this a step further using the method superRefine(), which we won't cover in here but it is good to know about.

Error Handling

Let's get back to this example:

import { z } from "zod";

const UserSchema = z
  .object({
    username: z.string(),
    coords: z.tuple([z.string(), z.date()]).rest(z.number()),
  })
  .strict();

type User = z.infer<typeof UserSchema>;

const user = {
  username: "rmj",
  coords: [1, new Date(), 3, 4, 5, 6, 7, 3],
};

console.log(UserSchema.safeParse(user));

As you can see we provided a number 1 to where a string was expected on coords.

This will output the error:

{
    "success": false,
    "error": {
        "issues": [
            {
                "code": "invalid_type",
                "expected": "string",
                "received": "number",
                "path": [
                    "coords",
                    0
                ],
                "message": "Expected string, received number"
            }
        ],
        "name": "ZodError"
    },
    "_error": {
        "issues": [
            {
                "code": "invalid_type",
                "expected": "string",
                "received": "number",
                "path": [
                    "coords",
                    0
                ],
                "message": "Expected string, received number"
            }
        ],
        "name": "ZodError"
        "errors": ...,
		"formErrors": ...,
		...
    }
}

Using those errors directly can be really dificult, what is recommended tough is customizing your error messages like:

const UserSchema = z
  .object({
    username: z
      .string({
        description: "Must be a String",
        required_error: "This is REQUIRED",
      })
      .min(3, "It should be 3 or more"),
    coords: z.tuple([z.string(), z.date()]).rest(z.number()),
  })
  .strict();

Basically, inside each validation you can pass parameters about what to show when parsing validation fails.

In this case we could set the username with undefined to see the required_error message, or provide a number to it, or even a small 2 letters string.

Those would all fail with the previded messages being send after validation.

As you can see this is not always great to reach for messages, that's the reason one could recommend a plugin: zod-validation-error.

This will give us really easy validation messages with 1 line of code.

First start by installing it:

pnpm i zod-validation-error

We can use like this:

import { z } from "zod";
import { fromZodError } from "zod-validation-error";

const UserSchema = z
  .object({
    username: z
      .string({
        description: "Must be a String",
        required_error: "This is REQUIRED",
      })
      .min(3, "It should be 3 or more"),
    coords: z.tuple([z.string(), z.date()]).rest(z.number()),
  })
  .strict();

type User = z.infer<typeof UserSchema>;

const user = {
  username: "ab",
  coords: ["test", new Date(), 3, 4, 5, 6, 7, 3],
};

const result = UserSchema.safeParse(user);

if (!result.success) {
  console.log(fromZodError(result.error).message);
}

And of course, this is not the total best thing to use for everything, since this is programatically, but it is good enough for most user responses you can customize.

Wrap Up

This is it! Happy Zodding!