Skip to content
Chapter 42Lesson 1

The eight builders

The Zod 4 schema builders that turn untrusted input into a validated value and a TypeScript type from one declaration.

A Client Component’s <form> submits to a Server Action. Before that action writes a row, it has to know what it received: that email is a string, quantity a positive integer, status one of a few legal values, and tags an array of strings. None of that is guaranteed. The action holds an unknown, a value off the wire the runtime makes no promises about.

Earlier in the course you learned to parse that unknown at every wire seam with a Zod schema. By the end of this lesson you’ll have the eight builders behind roughly ninety percent of the validation a SaaS app ships, assembled one question at a time from that single invoice-creation input.

A Zod schema is one declaration that does two jobs. At runtime it is a value with a .parse() method that takes an unknown, checks it against the shape you described, and either returns a typed value or throws. It is also a TypeScript type: the exact shape a successful parse returns, computed from the schema rather than written by hand.

import { z } from 'zod';
const invoiceSchema = z.object({
email: z.string(),
quantity: z.number(),
});
type Invoice = z.infer<typeof invoiceSchema>;

A value that exists at runtime. It exposes .parse(input), which takes an unknown, validates it, and returns a typed object, and .safeParse(input), which does the same without throwing.

import { z } from 'zod';
const invoiceSchema = z.object({
email: z.string(),
quantity: z.number(),
});
type Invoice = z.infer<typeof invoiceSchema>;

This reads the type out of the schema. Invoice is { email: string; quantity: number }, but you never typed those words: the type falls out of the schema, so there is no second, hand-written interface that could drift away from it.

1 / 1

Earlier you wrote a finite domain like type Status = 'draft' | 'sent' | 'paid' by hand. Here the relationship flips: you write the runtime validator, and the type is computed from it. The word for that is infer . z.infer<typeof invoiceSchema> says give me the type this schema would produce, and TypeScript works it out.

Add a bio field to the schema below and watch both sides move together: the ^? query gains the field, and the fixtures table re-checks each input against the new contract.

Add a required bio: z.string() field to the schema. Watch two things move at once — the ^? query gains bio: string, and the name only input flips to a failure, because the contract now requires the field you just added. One declaration, both sides.

Booting type-checker…
Test scenario Value
name and bio {"name":"Ada","bio":"mathematician"}
name only {"name":"Ada"}
bio only {"bio":"mathematician"}

At the bottom of every schema sit the primitives: one per JavaScript primitive type. Each accepts the matching value and rejects everything else.

lib/primitives-demo.ts
z.string(); // any string
z.number(); // any number (including floats)
z.boolean(); // true or false
z.date(); // a Date instance
z.bigint(); // a bigint

A sixth, z.symbol(), almost never appears in application code; these five are the working set.

A primitive demands a value, so z.string() rejects undefined. Real inputs have holes, though: an optional field, a column the user never filled in. Three wrappers express “this might be absent,” differing in which kind of absent they allow.

lib/optional-demo.ts
z.string().optional(); // string | undefined
z.string().nullable(); // string | null
z.string().nullish(); // string | null | undefined

.optional() admits undefined, a missing field; .nullable() admits null, a field present but explicitly empty; .nullish() admits both. Inside an object, any of these also makes the field’s key optional in the inferred type, so bio: z.string().optional() becomes bio?: string | undefined.

Prefer .optional() over .nullable(). TypeScript models a possibly-missing field with ?:, not | null, and undefined is the absence the language already leans on. Reach for .nullable() only when null is a deliberate value in your domain: a nullable database column where null means “explicitly cleared,” distinct from “never set.”

Object schemas and the unknown-key decision

Section titled “Object schemas and the unknown-key decision”

z.object is the workhorse, the one you’ll write more than every other builder combined. It maps keys to schemas, validates field by field, and infers the object type. The part you haven’t seen is the decision hiding inside it.

By default, z.object strips unknown keys silently. A key your schema doesn’t declare doesn’t fail the parse; it just vanishes from the output, with no error and no warning.

For a shape you built and trust, that’s harmless tidying. At an untrusted boundary it’s a problem. Picture a client POSTing { email, password, isAdmin: true } against a schema declaring only email and password: the parse succeeds, isAdmin is dropped, and your code moves on never knowing the client sent a field it had no business sending. Honest form bug or someone probing your endpoint, the signal is swallowed instead of surfaced.

The three object builders differ only in what they do with that extra key.

const credentialsSchema = z.object({
email: z.string(),
password: z.string(),
});
credentialsSchema.parse({ email: 'ada@x.com', password: 'hunter2', isAdmin: true });
// ✓ parses → { email: 'ada@x.com', password: 'hunter2' } ← isAdmin silently dropped

Strips silently. Harmless tidying for input you built yourself; for anything off the wire it hides a contract mismatch.

z.strictObject is the one to internalize: at a Server Action input or an API request body, an unexpected key means something is wrong and you’d rather find out. z.looseObject is the rare opposite.

Older code writes these as method chains, z.object({...}).strict() and z.object({...}).passthrough(); the top-level z.strictObject and z.looseObject builders are the Zod 4 form you’ll write from here on.

The syntax is three names; the durable skill is matching each boundary to its mode.

Each item is a boundary where data enters your code. Sort it into the object mode you'd reach for there. Drag each item into the bucket it belongs to, then press Check.

z.strictObject Extra key = a bug worth surfacing
z.looseObject Forward extras you don't control
z.object Trusted shape, tidy the rest
A Server Action input from a form submission
An incoming public API request body
A webhook payload from a vendor whose docs lag their API
A config object you just built yourself in the same file
Narrowing a row you just read from your own database

z.array takes one schema and validates a list where every element matches it. z.array(z.string()) accepts a string array and infers as string[]. Length bounds chain on: a tags field needing at least one entry and at most a hundred is z.array(z.string()).min(1).max(100).

z.tuple validates a fixed-length list where each position has its own type. z.tuple([z.string(), z.number(), z.boolean()]) accepts exactly a three-element array of [string, number, boolean] in that order, and infers as that tuple type.

lib/collections-demo.ts
z.array(z.string()).min(1).max(100); // string[]
z.tuple([z.string(), z.number(), z.boolean()]); // [string, number, boolean]

Reach for z.tuple only when length is fixed and position carries meaning, like a coordinate pair or a parsed CSV row. The common slip is using it for “an array whose elements might be one of two types.” A list of values each a string or a number is not a tuple; it is a uniform array of a union, z.array(z.union([z.string(), z.number()])).

Some fields aren’t “any string”; they’re one of a fixed, known set. An invoice’s status is draft, sent, paid, or overdue, and nothing else is legal. This is a finite domain, and Zod lets you enforce it at runtime.

The atom is z.literal. z.literal('paid') accepts only the exact string 'paid' and infers as the singleton type 'paid' rather than string.

z.enum(['draft', 'sent', 'paid', 'overdue']) is the tool for a finite string domain. It validates exactly those four values, rejects everything else, and infers as the union 'draft' | 'sent' | 'paid' | 'overdue', the very type you’d write by hand. It also gives you an .enum accessor: an object you can index to reference a legal value in code without retyping the string literal.

lib/status-accessor.ts
const invoiceStatus = z.enum(['draft', 'sent', 'paid', 'overdue']);
type InvoiceStatus = z.infer<typeof invoiceStatus>;
// 'draft' | 'sent' | 'paid' | 'overdue'
const defaultStatus = invoiceStatus.enum.draft;
// 'draft' — a legal value referenced without a loose string literal

Why z.enum over spelling out the alternatives? Because z.enum(['draft', 'sent', 'paid', 'overdue']) is exactly equivalent to z.union([z.literal('draft'), z.literal('sent'), z.literal('paid'), z.literal('overdue')]): same validation, same inferred type. The enum form is just shorter, faster to check, and hands you the .enum accessor for free.

One sharp edge trips people up. Pass the array of values inline, as above, and Zod reads the literal strings directly and infers the narrow union. Hoist the array to a variable first and TypeScript widens it to string[] before z.enum sees it, collapsing the enum to validating any string. The fix is as const, which freezes the variable as a tuple of literals.

lib/status-demo.ts
const broken = ['draft', 'sent', 'paid', 'overdue'];
z.enum(broken); // ✗ inferred as z.ZodEnum<string> — accepts ANY string
const statuses = ['draft', 'sent', 'paid', 'overdue'] as const;
z.enum(statuses); // ✓ inferred as the four-value union

Sometimes a field isn’t one shape but one of several. A notification might be an email with a to address or an SMS with a phone number.

The blunt tool is z.union. z.union([z.string(), z.number()]) accepts either and infers as string | number. Reach for it only for shapeless alternatives: a value that is a string or a number with no further structure.

For tagged variants, shapes told apart by a shared field whose value says which one you have, the senior default is z.discriminatedUnion.

lib/notification-schema.ts
const notificationSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('email'), to: z.string() }),
z.object({ kind: z.literal('sms'), phone: z.string() }),
]);

The first argument names the discriminator : the field every branch shares, typed as a distinct literal in each. A value carrying kind: 'email' is an email; one carrying kind: 'sms' is an SMS.

Why not a plain z.union of the two objects? A union tries each branch in turn and takes the first full match. With many branches that’s slow, and when none match it can only stack up every branch’s complaints. z.discriminatedUnion reads the discriminator first and routes straight to the one branch that claims it: validation is faster, the intent is in the schema, and a failed parse points at the branch you meant instead of a wall of errors.

Both tabs validate the same broken input, an email notification missing its to field.

const notificationSchema = z.union([
z.object({ kind: z.literal('email'), to: z.string() }),
z.object({ kind: z.literal('sms'), phone: z.string() }),
]);
notificationSchema.parse({ kind: 'email' });
// ✗ fails — but the error stacks issues from BOTH branches

The error blames both branches. You meant email, but the union reports failures against email and sms, leaving you to sort out which mattered.

Reach for this on any tagged variant crossing a boundary: a request body, a webhook, a notification payload. Earlier you learned to make illegal states unrepresentable in the type system; a discriminated union makes them unparseable too, since a value mixing fields from two branches won’t typecheck and won’t parse.

The exercise starts with a loose object, a single string status plus a pile of optional fields, that permits a combination that should never exist. Rewrite it as a z.discriminatedUnion on the tag, and watch the ^? query turn the type into a proper tagged union.

This loose object lets status: 'success' arrive with an error set — an impossible state. Rewrite it as a z.discriminatedUnion('status', [...]) with one z.object branch per status: loading carries nothing, success requires data, error requires error. The 'success with error' row stays red until you do — then watch the ^? query become a proper tagged union.

Booting type-checker…
Test scenario Value
loading {"status":"loading"}
success with data {"status":"success","data":"ok"}
error with message {"status":"error","error":"boom"}
success with error (impossible) {"status":"success","error":"boom"}

Two builders you’ll read more often than write.

z.unknown() accepts anything and infers as unknown: the schema-layer version of “parse to unknown, then narrow.” Use it for a payload whose shape you haven’t validated yet, and as the placeholder a jsonb column gets until you give it a real shape. z.never() is the opposite: it accepts nothing and infers as never. It’s rare in application code, surfacing mostly where a type has already handled every case.

lib/edges-demo.ts
z.unknown(); // unknown — accepts anything, narrow it later
z.never(); // never — accepts nothing

Where schemas live and what they’re named

Section titled “Where schemas live and what they’re named”

That’s all eight builders. The last piece is convention: where a schema lives and what it’s called.

A schema lives in your /lib directory, in the same file as the type it produces. The schema constant is camelCase, the inferred type alias is PascalCase, and the type sits on the line directly below the schema. A canonical entity shape is invoiceSchema, with type Invoice = z.infer<typeof invoiceSchema> underneath; an action-input shape that mirrors a mutation is createInvoiceSchema. One declaration, one file, the runtime validator and the compile-time type side by side, so they can never drift apart.

Here is the invoice input from the start of this lesson, now fully assembled from the builders you just learned.

lib/invoice.ts
import { z } from 'zod';
export const createInvoiceSchema = z.object({
email: z.string(),
quantity: z.number().int().positive(),
status: z.enum(['draft', 'sent', 'paid', 'overdue']),
tags: z.array(z.string()).min(1).max(100),
});
export type CreateInvoice = z.infer<typeof createInvoiceSchema>;
// { email: string; quantity: number; status: 'draft' | 'sent' | 'paid' | 'overdue'; tags: string[] }

Read it back against the builders: a z.string() for email (its email-format builder is the next lesson), a z.number().int().positive() for quantity, a z.enum for the finite status domain, and a z.array(z.string()) with length bounds for tags. One z.object, four fields, and a single z.infer that hands the action its input type.

Try it before moving on. The playground is prefilled with this schema and three inputs: one valid, one with a status outside the legal set, and one carrying an extra key that the default object mode quietly accepts.

The official Zod 4 documentation is the reference you’ll keep open while writing schemas, the Total TypeScript tutorial turns these builders into hands-on exercises, and the Zod Playground is the same live runtime the callout above embeds.