DEV Community

Cover image for Zod 101 - A Typescript-first Schema Validation Library
Tony Kharioki
Tony Kharioki

Posted on

Zod 101 - A Typescript-first Schema Validation Library

The Trailer

Typescript is great for checking variables at the top level, e.g.: you can specify that the age property for a User should be a number. However you can't always be sure what you're going to get from a form input. Typescript will present an error if it's not a number but on its own it doesn't know if a number is too low or too high to this usecase.

There's a similar problem with 3rd party API responses.

Why Zod?

Zod allows you to work around uncertainity by checking types at the runtime level as well at the type level.
Typescript checks type on the type level, Zod checks type on the runtime level.

What is Zod?

Zod is a Typescript-first schema declaration and validation library. It is designed to be very developer friendly. It aims to eliminate duplicative type declarations. With Zod you can declare a validator once and Zod will automatically infer the static Typescript type for you.

We'll be using Vitest to run the tests


Runtime Checking

consider the following code:



export const toString = (num: unknown) => {
  return String(num);
};


Enter fullscreen mode Exit fullscreen mode

We are taking in a num variable that we've typed as unknown. We then return a stringified version of it unknown. This means that we can call toString() with anything we want on the type level, including object types or undefined.



toString(1); // "1"
toString("1"); // "1"
toString({}); // "[object Object]"
toString(undefined); // "undefined"


Enter fullscreen mode Exit fullscreen mode

For now all these would work, but we want to prevent this at the runtime level. If we call toString() with a string, we want to throw an error and say it expected a number but received a string.

Solution - update the function to check the type at the runtime level:



const numberParser = z.number();

export const toString = (num: unknown) => {
  const parsed = numberParser.parse(num);
  return String(parsed);
};

// Tests
it("should throw a runtime error when called with not a number", () => {
  expect(() => toString("123")).toThrowError(
    "Expected number, received string",
  );
});


it("should return a strung when called with a number", () => {
  expect(toString(123)).toBeTypeOf("string");
});


Enter fullscreen mode Exit fullscreen mode

We create a numberParser using the z.number() function. This is a ZodSchema object that we can use to parse the num variable. If the num variable is not a number, the parse() function will throw an error. This means that any variable we create by calling numberParser.parse() will be typed as a number, and our tests will pass.


Verify Unknown APIs with an Object Schema

Zod is commonly used for verifying unknown APIs. Consider the following API fetch:



const PersonResult = z.unknown();

export const fetchStarWarsPersonName = async (id: string) => {
  const data = await fetch("https://swapi.dev/api/people/" + id).then((res) =>
    res.json(),
  );

  const parsedData = PersonResult.parse(data);

  return parsedData.name;
};


Enter fullscreen mode Exit fullscreen mode

In the above instance the PersonResult variable is created with z.unknown(). This means that we are saying that we don't know what the API response will be.

The test does pass - it's the issue of PersonResult being typed as unknown that we want to solve.

Solution - update the PersonResult variable to be a ZodObject:



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

export const fetchStarWarsPersonName = async (id: string) => {
  const data = await fetch("https://swapi.dev/api/people/" + id).then((res) =>
    res.json(),
  );

  const parsedData = PersonResult.parse(data);

  return parsedData.name;
};

// Tests
it("should return the name", async () => {
  expect(await fetchStarWarsPersonName("1")).toEqual("Luke Skywalker");
  expect(await fetchStarWarsPersonName("2")).toEqual("C-3PO");
});


Enter fullscreen mode Exit fullscreen mode

Any additional keys you add to the PersonResult ZodObject from the original API response will be included in the parsedData variable.


Create an Array of Custom Types

Consider the following API fetch:



const StarWarsPerson = z.unknown();

const StarWarsPeople = z.unknown();

export const fetchStarWarsPeople = async () => {
  const data = await fetch("https://swapi.dev/api/people/").then(response => response.json());

  const parsedData = StarWarsPeople.parse(data);

  return parsedData.results;
}


Enter fullscreen mode Exit fullscreen mode

The correct way to solve this challenge is to create an object that references other objects. In this case StarWarsPeople will be a z.object that contains results.

Declaring arrays of objects like this is one of the most common use cases for z.array(), especially when referencing types you already created.



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

const StarWarsPeople = z.object({
  results: z.array(StarWarsPerson)
});

export const fetchStarWarsPeople = async () => {
  const data = await fetch("https://swapi.dev/api/people/").then(response => response.json());

  const parsedData = StarWarsPeople.parse(data);

  return parsedData.results;
}

// Tests
it("Should return the name", async () => {
  const people = await fetchStarWarsPeople();
  expect(people[0].name).toEqual("Luke Skywalker");
});


Enter fullscreen mode Exit fullscreen mode

Extracting a Type from a Parser Object

In the above example we created a StarWarsPerson object that we used to parse the results array. We can extract the type from the StarWarsPerson object using the infer keyword.



type StarWarsPersonType = z.infer<typeof StarWarsPerson>;


Enter fullscreen mode Exit fullscreen mode

This will allow us to use the StarWarsPersonType type in other places in our codebase.



import { z } from "zod";

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

const StarWarsPeople = z.object({
  results: z.array(StarWarsPerson)
});

const logStarWarsPeople = (data: z.infer<typeof StarWarsPeople>) => {
  data.results.map(person => console.log(person.name));
}


Enter fullscreen mode Exit fullscreen mode

Make Schemas Optional

Zod is also useful for validating user input. Consider the following form:



import { expect, it } from "vitest";
import { z } from "zod";

const Form = z.object({
  name: z.string(),
  phoneNumber: z.string(),
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};

// TESTS

it("Should validate correct inputs", async () => {
  expect(() =>
    validateFormInput({
      name: "Matt",
    }),
  ).not.toThrow();

  expect(() =>
    validateFormInput({
      name: "Matt",
      phoneNumber: "123",
    }),
  ).not.toThrow();
});

it("Should throw when you do not include the name", async () => {
  expect(() => validateFormInput({})).toThrowError("Required");
});


Enter fullscreen mode Exit fullscreen mode

The phoneNumber field is optional, but we want to make sure that the name field is required. We can do this by adding .optional() to the phoneNumber field.



const Form = z.object({
  name: z.string(),
  phoneNumber: z.string().optional(),
});


Enter fullscreen mode Exit fullscreen mode

Set a Default Value

Consider the following form:



import { z } from "zod";

const Form = z.object({
  repoName: z.string(),
  keywords: z.array(z.string()).optional(),
});

export const validateFormInput = (values: unknown) => {
  const parsedValues = Form.parse(values);

  return parsedValues;
}


Enter fullscreen mode Exit fullscreen mode

Suppose we want to default the keywords field to an empty array if the user does not provide any keywords. We can do this by adding .default() to the keywords field.



import { expect, it } from "vitest";
import { z } from "zod";

const Form = z.object({
  repoName: z.string(),
  keywords: z.array(z.string()).default([]),
});

export const validateFormInput = (values: unknown) => {
  const parsedValues = Form.parse(values);

  return parsedValues;
}

// Tests
it("Should include keywords if passed", async () => {
  const result = validateFormInput({ repoName: "vitest", keywords: ["test", "testing"] });

  expect(result).toEqual({ repoName: "vitest", keywords: ["test", "testing"] });
  expect(result.keywords).toEqual(["test", "testing"]);
});

it("Should not include keywords if not passed", async () => {
  const result = validateFormInput({ repoName: "vitest" });

  expect(result.keywords).toEqual([]);
});


Enter fullscreen mode Exit fullscreen mode

The Input is Different than the Output
The validateFormInput function returns an object that is different than the input. The keywords field is always an array, even if the user does not provide any keywords.

If we create a FormInput and FornOutput type, we can use the z.infer keyword to extract the type from the Form object.



type FormInput = z.infer<typeof Form>;
type FormOutput = z.infer<typeof Form>;


Enter fullscreen mode Exit fullscreen mode

Introducing z.input
The above input is not quite correct because when we input into validateFormInput, we don't pass in any keywords. We can use the z.input keyword to extract the input type from the Form object.



type FormInput = z.input<typeof Form>;
type FormOutput = z.infer<typeof Form>;


Enter fullscreen mode Exit fullscreen mode

Being specific with Allowed Types

Consider the following form:



const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.string(),
});


Enter fullscreen mode Exit fullscreen mode

Suppose we want to make sure that the privacyLevel field is either public or private. We can do this by adding .enum() to the privacyLevel field.



const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.enum(["public", "private"]),
});


Enter fullscreen mode Exit fullscreen mode

Another way is to use the z.union keyword and pass in an array of z.literal objects. You can use .literal() to represent any literal value.



const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.union([z.literal("public"), z.literal("private")]),
});


Enter fullscreen mode Exit fullscreen mode

The .enum() keyword is arguably more readable, but the z.union keyword is more flexible.


Complex Schema Validation

Consider the following form:



import { z } from "zod";

const Form = z.object({
  name: z.string(),
  phoneNumber: z.string().optional(),
  email: z.string(),
  website: z.string().optional(),
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};


Enter fullscreen mode Exit fullscreen mode

Suppose we want to add a few constraints on what the values can be. We want to validate that the name is at least 1 character, the phone number to have the right amount of digits, and we want the website to be a valid URL and email to be a valid email address.



import { z } from "zod";

const Form = z.object({
  name: z.string().min(1),
  phoneNumber: z.string().min(5).max(20).optional(),
  email: z.string().email(),
  website: z.string().url().optional(),
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};


Enter fullscreen mode Exit fullscreen mode

Reducing duplicated code by composing schemas

Consider the following code:



import { z } from "zod";

const User = z.object({
  id: z.string().uuid(),
  name: z.string(),
});

const Post = z.object({
  id: z.string().uuid(),
  title: z.string(),
  body: z.string(),
});

const Comment = z.object({
  id: z.string().uuid(),
  text: z.string(),
});


Enter fullscreen mode Exit fullscreen mode

There are a few ways we can refactor this code.

Simple solution
We can strip out the id into its own type. Then we can use the Id type in the User, Post, and Comment types.



const Id = z.string().uuid();

const User = z.object({
  id: Id,
  name: z.string(),
});

const Post = z.object({
  id: Id,
  title: z.string(),
  body: z.string(),
});

const Comment = z.object({
  id: Id,
  text: z.string(),
});


Enter fullscreen mode Exit fullscreen mode

Better solution - use the Extend method
Another solution would be to create a base object ObjectWithId and extend it with the User, Post, and Comment types.



const ObjectWithId = z.object({
  id: z.string().uuid(),
});

const User = ObjectWithId.extend({
  name: z.string(),
});

const Post = ObjectWithId.extend({
  title: z.string(),
  body: z.string(),
});

const Comment = ObjectWithId.extend({
  text: z.string(),
});


Enter fullscreen mode Exit fullscreen mode

Use Merge
We can also use the .merge keyword to merge the ObjectWithId and the User, Post, and Comment types.



const User = ObjectWithId.merge(
  z.object({
    name: z.string(),
  }),
);


Enter fullscreen mode Exit fullscreen mode

Merging is generally used when two different types are being combined, rather than extending a single type.


Transforming Data from within a Schema

Another useful feature of Zod is manipulating data from an API response after parsing. Consider the following API response:



import { z } from "zod";

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

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

export const fetchStarWarsPeople = async () => {
  const response = await fetch("https://swapi.dev/api/people/");
  const data = await response.json();

  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData;
};


Enter fullscreen mode Exit fullscreen mode

We can use the .transform keyword to manipulate the data from the API response. In this case, we want to transform the name field and add a new field called nameAsArray.



import { z } from "zod";

const StarWarsPerson = z
  .object({
    name: z.string(),
  })
  .transform((person) => ({
    ...person,
    nameAsArray: person.name.split(" "),
  }));

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

export const fetchStarWarsPeople = async () => {
  const data = await fetch(
    "https://www.totaltypescript.com/swapi/people.json",
  ).then((res) => res.json());

  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData.results;
};

// TESTS

it("Should resolve the name and nameAsArray", async () => {
  expect((await fetchStarWarsPeople())[0]).toEqual({
    name: "Luke Skywalker",
    nameAsArray: ["Luke", "Skywalker"],
  });
});


Enter fullscreen mode Exit fullscreen mode

You can find the complete code on this repo

Zod Gif

When you meet someone who is not using Vitest to test

Top comments (0)