TypeScript and the runtime · 4 / 7
lesson 4

Schema validation with Zod

Define the shape once. Get a runtime validator and a TypeScript type from the same line.

~ 18 min read·lesson 4 of 7
0 / 7

You've seen what hand-written predicates and assertions cost — every type gets a parallel function that has to be kept in sync. The trick that breaks the pattern is to write the shape once and have the language give you both halves: a runtime check and a compile-time type. Zod is the most common library that does this in TypeScript. This lesson walks through how it works and the patterns that turn it into a default move.

Defining a schema

A Zod schema describes the expected shape of a value. You build one with the z builder.

user-schema.ts
import { z } from "zod";

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

User is a value, not a type. It's an object with methods on it — parse, safeParse, and a few more. Reading the schema as English: "an object with an id that's a string, a name that's a string, and an age that's a number." The schema is exactly the kind of thing you'd write in a comment to describe a User — except it runs.

Each field uses a small builder: z.string(), z.number(), z.boolean(), z.array(...), z.object(...). They compose. An array of users is z.array(User). An optional field is z.string().optional(). A nullable one is z.string().nullable(). The shape is built like Lego.

more-schemas.ts
const Post = z.object({
id: z.string(),
title: z.string(),
body: z.string(),
publishedAt: z.string().datetime().nullable(),
tags: z.array(z.string()),
});

The .datetime() on publishedAt adds a constraint — the string has to look like an ISO timestamp. The .nullable() after it allows the field to be null. Order matters; .nullable().datetime() would be different and a mistake (you'd require null to also match the datetime pattern).

Tip

Schemas are values you can pass around. Pull a schema into a constant in one file and import it anywhere you'd reach for the type. One source, two outputs.

parse vs safeParse

A schema doesn't do anything by itself. You hand it a value and ask it to check.

parse-throws.ts
const raw: unknown = await res.json();
const user = User.parse(raw);
/* user is fully typed { id: string; name: string; age: number } */

parse is the throw-on-failure flavor. If raw matches the schema, the call returns the value with the right type. If anything fails — wrong field, missing key, wrong nested shape — Zod throws a detailed error describing exactly where the mismatch was.

This is great when you want to fail at the boundary. Bad data crashes loud, with a message you can read.

safe-parse.ts
const result = User.safeParse(raw);

if (!result.success) {
log.warn("user payload invalid", result.error.issues);
return null;
}

return result.data;   /* fully typed User */

safeParse returns a tagged result instead of throwing. The shape is { success: true, data } | { success: false, error }. That's a discriminated union — you check success first, and TypeScript narrows down which branch you're in. No try / catch, just an if. Use this when the failure path is a real path you want to handle gracefully — say, falling back to default settings, or showing a "couldn't load profile" UI.

The two are alternatives, not replacements. Pick whichever matches the situation:

  • Loading config at startup → parse (no fallback, fail loud).
  • Reading a possibly-stale localStorage entry → safeParse (fall back to defaults).
  • Validating user input in a form → safeParse (show field errors).
  • Validating a server response in a critical flow → parse if you want to crash, safeParse if you want to log and fall back.
check your understanding
You call schema.parse(raw) with a value missing a required field. What happens?
check your understanding
A form needs to show "email is invalid" on the email input. Which Zod method fits?

Inferring the type

Schemas are runtime. TypeScript types are compile-time. The whole reason to use a library like this is that one schema can produce both — z.infer is how you get the type.

infer.ts
const User = z.object({
id: z.string(),
name: z.string(),
age: z.number(),
});

type User = z.infer<typeof User>;
/* User is now { id: string; name: string; age: number } */

Read z.infer<typeof User> carefully. typeof User is the type of the schema value. z.infer is a generic that walks that type and produces the type of the data the schema validates. The result is a normal TypeScript type — you can use it in function signatures, return types, anywhere.

Naming the type the same as the schema works because TypeScript has separate namespaces for types and values. User the schema (a value) and User the type live in different worlds and don't collide. This is one of those cases where the language conventions actually help.

use-the-type.ts
type User = z.infer<typeof User>;

function greet(user: User) {
return "hi " + user.name;
}

const data = User.parse(await res.json());
greet(data);   /* fully typed */

The type and the runtime check came from one definition. When you change the schema — add a lastSeen field, mark age optional — the inferred type changes with it. There is no second place to update.

Tip

Define the schema next to the type alias, ideally in the same file. The schema is the source; the type follows it. Code reviewers can spot drift instantly when they're together.

Richer shapes

Real data isn't always a flat object. Zod's builders cover the cases you'll meet most.

rich-schemas.ts
const Email = z.string().email();
const Url = z.string().url();
const PositiveInt = z.number().int().positive();

const Comment = z.object({
id: z.string().uuid(),
body: z.string().min(1).max(2000),
author: z.object({
  id: z.string().uuid(),
  handle: z.string(),
}),
parentId: z.string().uuid().nullable(),
reactions: z.array(z.enum(["like", "heart", "laugh"])),
});

Walk the changes. .email() and .url() add format checks. .int().positive() constrains a number. .min(1).max(2000) bounds a string's length. .uuid() constrains the format. z.enum([...]) constrains the value to a known set; the inferred type is "like" | "heart" | "laugh" — a TypeScript union.

The schema reads like a short specification of the type — including the constraints that the type system alone cannot express (int, positive, min, max). When safeParse fails on a comment with an empty body, the error tells you "string must be at least 1 character at body."

For unions, Zod has z.union([A, B]) and the more useful z.discriminatedUnion(key, [...]) for tagged unions.

discriminated.ts
const Event = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), targetId: z.string() }),
z.object({ type: z.literal("scroll"), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string() }),
]);

type Event = z.infer<typeof Event>;
/* { type: "click"; targetId: string } | { type: "scroll"; y: number } | ... */

The first argument names the field that picks the variant. Each schema in the array is one variant. The inferred type is a discriminated union — the kind that lets you switch (event.type) and have TypeScript narrow each branch to one shape. This is the move that makes domain modeling pleasant.

check your understanding
You define z.enum(["draft", "published", "archived"]) as the schema for a status field. What is the inferred TypeScript type?

Schema as source of truth

The pattern that ties everything together: define the schema once, derive the type from it, and use the schema at every boundary that touches values of that type.

source-of-truth.ts
/* schemas/user.ts */
import { z } from "zod";

export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
});

export type User = z.infer<typeof UserSchema>;
api/users.ts
import { UserSchema, type User } from "./schemas/user";

export async function fetchUser(id: string): Promise<User> {
const res = await fetch("/api/users/" + id);
return UserSchema.parse(await res.json());
}

fetchUser returns Promise<User> honestly. The schema is the gate; everything past the call is typed. If the API ever drops email, the parse throws the moment the response arrives, with a message that points at the missing field.

This is the shape you want for most boundaries:

  • One file per domain object: schema and type alias side by side.
  • Every fetch wrapped in parse (or safeParse if there's a fallback).
  • Every form input run through the same schema before submission.
  • Every localStorage read parsed before use.

The one schema becomes the contract that ties UI, fetch, and storage together. When the contract changes, you change one place, and the type errors lead you to every site that needs an update.

Watch out

Zod's runtime check has a small cost — it walks the value. For large arrays validated on a hot path, prefer to validate at the boundary once and trust the type internally. Don't parse the same data on every render.

check your understanding
You have one UserSchema, but two type aliases: type User hand-written in types.ts, and the schema lives in schemas.ts. The hand-written type has an extra avatar field. What's wrong?
check your understanding
You add .optional() to the name field of a schema. What changes for code that does user.name.toUpperCase()?
check your understanding
A page loads a list of orders that's 5,000 items long, render-critical, and validated once on receipt. Where should the schema parse run?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript and the runtime
0 of 7 read