Zod 101 - A Typescript-first Schema Validation Library
Tony Kharioki
Posted on March 20, 2023
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);
};
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"
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");
});
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;
};
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");
});
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;
}
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");
});
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>;
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));
}
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");
});
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(),
});
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;
}
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([]);
});
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>;
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>;
Being specific with Allowed Types
Consider the following form:
const Form = z.object({
repoName: z.string(),
privacyLevel: z.string(),
});
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"]),
});
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")]),
});
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;
};
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;
};
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(),
});
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(),
});
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(),
});
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(),
}),
);
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;
};
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"],
});
});
You can find the complete code on this repo
When you meet someone who is not using Vitest to test
Posted on March 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.