Schema validation with static type inference
On this page
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:
deno main.tsEvaHowever, 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:
deno main.tsundefinedAnd 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:
deno main.tserror: 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:28Schema
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:10To 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 }}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 typetype Player = z.infer<typeof Player>;
// use it in your codeconst 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>// numberSchema
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 typesz.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
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:
import * as z from "zod";
const productSchema = z.object({ id: z.number().int(), name: z.string(), price: z.number(), tags: z.array(z.string()),});
// To extract the TypeScript type from the schema:type Product = z.infer<typeof productSchema>;
const product: Product = productSchema.parse({ "id": 65, "name": "A green door", "price": 12.50, "tags": ["home", "green"],});
console.log(product.name);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:
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:
> bunx ts-json-schema-generator -p .\typicode.ts -f .\tsconfig.json > .\typicode.jsonValidate 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.
dummyjson.ts
export type Recipe = { id: number name: string ingredients: string[] instructions: string[] prepTimeMinutes: number cookTimeMinutes: number servings: number difficulty: string cuisine: string caloriesPerServing: number tags: string[] userId: number image: string rating: number reviewCount: number mealType: string[]}Exercises
https://www.totaltypescript.com/tutorials/zod