Zod - Schema

Schema validation with static type inference

Introduction

Zod is a TypeScript-first validation library.

In web applications, it is common to work with complex data that must be transmitted between different applications.

Using Zod, you can define schemas you can use to validate data, from a simple string to a complex nested object.

Json

When deserializing a JSON string, you can define its structure using a type to provide better autocompletion, error checking, and readability.

Modify the main.ts file:

type Person = {
name: string,
age: number
}
let str = '{ "name": "Eva", "age": 33}'
let person: Person = JSON.parse(str)
console.log(person.name)

If you run this code, it works without problems because the deserialized object has the name property:

Terminal window
deno main.ts
Eva

However, this is only informative, as JSON.parse does not verify that the string contains an object compatible with the variable type:

type Person = {
name: string,
age: number
}
let str = '{"brand": "Seat", "model": "Ibiza"}'
let person: Person = JSON.parse(str)
console.log(person.name)

If you run the code, the result is undefined because the deserialized object does not have the name property:

Terminal window
deno main.ts
undefined

And even if in this case the problem is harmless, the same is not true for this code:

type Person = {
name: string,
age: number
address: Address
}
type Address = {
street: string,
city: string
}
let str = '{"brand": "Seat", "model": "Ibiza"}'
let person: Person = JSON.parse(str)
console.log(person.address.city)

If you run it, you get a serious runtime error:

Terminal window
deno main.ts
error: Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'city')
console.log(person.address.city)
^
at file:///Users/david/Workspace/zod/main.ts:16:28

Schema

A JSON object does not include any context or metadata, and there is no way to know just by looking at the object what the properties mean or what the allowed values are.

Before you can do anything else, you need to define a schema that contains the description and restrictions that a JSON document must have to be compliant with that schema and, therefore, an instance of that schema.

import * as z from "zod";
const Player = z.object({
username: z.string(),
xp: z.number()
});

Given any Zod schema, use .parse to validate an input.

If it’s valid, Zod returns a strongly typed deep clone of the input.

const player = Player.parse({ username: "billie", xp: 100 });
console.log(player)

When validation fails, the .parse() method will throw a ZodError instance with granular information about the validation issues.

try {
Player.parse({ username: 42, xp: "100" });
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error);
}
}
ZodError: [
{
"expected": "string",
"code": "invalid_type",
"path": [
"username"
],
"message": "Invalid input: expected string, received number"
},
{
"expected": "number",
"code": "invalid_type",
"path": [
"xp"
],
"message": "Invalid input: expected number, received string"
}
]
at file:///Users/david/Workspace/zod/main.ts:10:10

To avoid a try/catch block, you can use the .safeParse() method to get back a plain result object containing either the successfully parsed data or a ZodError.

The result type is a discriminated union, so you can handle both cases conveniently.

const result = Player.safeParse({ username: 42, xp: "100" });
if (!result.success) {
result.error; // ZodError instance
} else {
result.data; // { username: string; xp: number }
}
Note

If your schema uses certain asynchronous APIs like async refinements or transforms, you’ll need to use the .safeParseAsync() method instead.

await schema.safeParseAsync("hello");

Inferring types

Zod infers a static type from your schema definitions.

You can extract this type with the z.infer<> utility and use it however you like.

// extract the inferred type
type Player = z.infer<typeof Player>;
// use it in your code
const player: Player = { username: "billie", xp: 100 };
console.log(player.username);

In some cases, the input & output types of a schema can diverge.

For instance, the .transform() API can convert the input from one type to another.

In these cases, you can extract the input and output types independently:

const mySchema = z.string().transform((val) => val.length);
type MySchemaIn = z.input<typeof mySchema>;
// => string
type MySchemaOut = z.output<typeof mySchema>; // equivalent to z.infer<typeof mySchema>
// number

Schema

https://zod.dev/api

To validate data, you must first define a schema.

Schemas represent types, from simple primitive values to complex nested objects and arrays.

Primitives

import * as z from "zod";
// primitive types
z.string();
z.number();
z.bigint();
z.boolean();
z.symbol();
z.undefined();
z.null();

Duck typing

Create a validation schema with two properties, id and name:

import * as z from "zod";
const schema = z.object({
id: z.number().int(),
name: z.string(),
});

You can now use the function you created to validate if an object is compliant with the productSchema schema:

const apple = { "id": 1, "name": "apple" };
const result = schema.safeParse(apple);
if (!result.success) {
console.log("apple is not an apple 🤨");
} else {
console.log("Let's eat the 🍎");
}

If you run the code, you can see that we can eat the apple because it is an object that has the properties required for a product:

Let's eat the 🍎

And we don’t eat the apple if it doesn’t have the required properties (despite being an apple):

const apple = { "name": "apple" };
const result = schema.safeParse(apple);
if (!result.success) {
console.log("apple is not an apple 🤨");
} else {
console.log("Let's eat the 🍎");
}

You already know that TypeScript uses “duck typing.”

Therefore, this object that represents a person, although correct at the code level, is not allowed in real life…

const eva = { "id": 1, "name": "Eva" }
const result = schema.safeParse(eva);
if (!result.success) {
console.log("apple is not an apple 🤨");
} else {
console.log("Let's eat the 🍎");
}

I know, I know, many years ago Eva could have been an edible product, but today it is not allowed 🫡

Anyway, this product, although didactic, is not very well designed for what we are using it for.

Modify the product schema and improve the code:

const schema = z.object({
id: z.number().int(),
name: z.string(),
icon: z.string(),
edible: z.boolean(),
});
type Product = z.infer<typeof schema>;
const json = JSON.parse(
'{ "id": 1, "name": "orange", "icon": "🍊", "edible": true }',
);
const result = schema.safeParse(json);
if (!result.success) {
console.log("product is not a product 🤨");
} else {
const product = result.data;
if (product.edible) {
console.log(`Let's eat the ${product.icon}`);
}
}

You can see that now we eat the orange 🍊 because it is an edible product.

And… it can still be improved more because TypeScript is “script” with types:

type Product = z.infer<typeof schema>;

Exercises

Task

Below is an object that belongs to a product catalog:

{
"id": 65,
"name": "A green door",
"price": 12.50,
"tags": [ "home", "green" ]
}

Create a product schema and infer its type:

Fetch

Normally, validation is performed on external data that you get through REST APIs.

It is very important that you validate that data because you can never be sure that the data you receive is correct.

Next, we will use “fake” data from https://jsonplaceholder.typicode.com/.

Modify the index.ts file:

const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users = await response.json()
console.log(users.map((user: { name: string }) => user.name))

This code downloads a JSON with all the users and shows the names of the users in the terminal:

Terminal window
bun run .\index.ts
[ "Leanne Graham", "Ervin Howell", "Clementine Bauch", "Patricia Lebsack", "Chelsey Dietrich",
"Mrs. Dennis Schulist", "Kurtis Weissnat", "Nicholas Runolfsdottir V", "Glenna Reichert",
"Clementina DuBuque"
]

Next, create the typicode.ts file with the corresponding types:

export type User = {
id: string
name: string
username: string
email: string
address: Address
phone: string
website: string
company: Company
}
type Address = {
street: string
suite: string
city: string
zipcode: string
geo: Geo
}
type Geo = {
lat: number
lng: number
}
type Company = {
name: string
catchPhrase: string
bs: string
}

Use the User type from the typicode.ts script and show the names of the companies where the users work:

import type { User } from "./typicode"
const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await response.json()
console.log(users.map(user => user.company.name))

Create the typicode.json file with the validation schema:

Terminal window
> bunx ts-json-schema-generator -p .\typicode.ts -f .\tsconfig.json > .\typicode.json

Validate the user data:

import Ajv from "ajv"
import type { User } from "./typicode"
import userSchema from "./typicode.json"
const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await response.json()
const ajv = new Ajv()
const validateUser = ajv.compile(userSchema)
users.filter(user => validateUser(user))
console.log(users.map(user => user.company.name))

Activity - DummyJSON

At DummyJSON, you have quite real test data.

1.- At the URL https://dummyjson.com/recipes, you have a list of recipes.

import type { Recipe } from "./dummyjson"
const response = await fetch('https://dummyjson.com/recipes')
const data = await response.json()
const recipes: Recipe[] = data.recipes
console.log(recipes.map(recipe => recipe.name))

Create the dummyjson.ts file with the corresponding types, validation schema, etc.

Exercises

https://www.totaltypescript.com/tutorials/zod

TODO

TODO