Skip to content
Chapter 42Lesson 1

The eight builders

Meet Zod 4, the schema library that turns untrusted input into a validated value and a TypeScript type from one declaration, starting with the eight builders most SaaS validation is made of.

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

You met this boundary earlier in the course, when you learned to parse unknown at every wire seam and trust nothing the network hands you. That lesson named the tool that does the parsing and then set it aside. The tool is a Zod schema, a runtime parser with a TypeScript type attached, and this whole chapter is built out of them. By the end of this lesson you’ll have the eight builders that make up roughly ninety percent of the validation any 2026 SaaS app ships, assembled one question at a time from that single invoice-creation input.

Everything else in this chapter rests on one idea, so it’s worth settling before you reach for a single builder. With it in place, the builders become tools you reach for on purpose rather than a tour of an API.

A Zod schema is one declaration that produces two things at once. At runtime it is a value: an object with a .parse() method that takes an unknown, checks it against the shape you described, and either hands back a typed value or throws. It is also the source of 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>;

This is a value, and it exists at runtime. It exposes .parse(input), which takes an unknown, validates it, and returns a typed object. It also exposes .safeParse(input), which does the same without throwing. You’ll reach for .safeParse constantly, and it gets its own lesson later in this chapter. The schema is what turns an untrusted input into a value you can trust.

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 didn’t type those words. You authored the schema, and the type fell out of it. There is no second, hand-written Invoice interface sitting next to this one that could drift away from it. One declaration does two jobs.

1 / 1

This is the core move. Earlier in the course you learned to write a finite domain like type Status = 'draft' | 'sent' | 'paid' by hand, as the disciplined way to type a value. Here the relationship flips: you write the runtime validator, and the type is computed from it. The schema is the single source of truth, and the type is downstream of it. You’ll see this pattern again later in this chapter, once your validators and your database start agreeing with each other.

The word doing the work in that second line is infer . If you’re coming from another language you might read it as a keyword, but it isn’t. It’s the act of computing a type from something rather than declaring it. z.infer<typeof invoiceSchema> says: give me the type this schema would produce, and TypeScript works it out.

The exercise below makes the two-things-at-once idea concrete. The schema starts with a single name field. Add a bio field to it and watch two things move together in the same card: the fixtures table re-checks each input, and the ^? query under the schema shows the inferred type gaining the new field. Runtime and type both update from one edit.

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"}

Every schema is built from smaller schemas, and at the bottom are the primitives: one schema per JavaScript primitive type. Each accepts the matching value and rejects everything else. These are straightforward, and the more interesting decisions come a tier up.

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

There’s a sixth, z.symbol(), for JavaScript symbols. It exists, but you’ll almost never validate one in application code, so that’s all it needs. The five above are the working set.

A primitive on its own demands a value, so z.string() rejects undefined. But real inputs have holes: an optional field, a column the user hasn’t filled in. Three wrappers express “this might be absent,” and they differ in exactly 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, meaning the field can be missing. .nullable() admits null, meaning the field is present but explicitly empty. .nullish() admits both. When one of these wraps a field inside an object, it also turns that field’s key optional in the inferred type: bio: z.string().optional() becomes bio?: string | undefined, the ?: you’d write by hand.

In 2026 you should prefer .optional() over .nullable(). In a TypeScript codebase, “this field might not be there” is modeled with ?:, not | null. The two ways of saying “absent” don’t need to coexist, and undefined is the one the language already leans on. Reach for .nullable() only when null is a deliberate value in your domain: a database column that is genuinely nullable, where null means “explicitly cleared” as distinct from “never set.” Outside that one exception, use .optional().

Object schemas and the unknown-key decision

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

z.object is the workhorse: you’ll write it more than every other builder combined. It takes a map of keys to schemas and validates an object field by field, and its inferred type is the object type with each field’s type filled in. You’ve already seen it. What you haven’t seen is the decision hiding inside it, and that decision is the central judgment call in this lesson.

Start with the part that can bite you. The default z.object strips unknown keys silently. If the input carries a key your schema doesn’t mention, the parse succeeds and that key simply vanishes from the output. There’s no error and no warning; the key is just gone.

That’s fine, and even convenient, for a shape you built yourself and trust. At an untrusted boundary it’s a problem. Picture a client POSTing { email, password, isAdmin: true } against a schema that only declares email and password. The parse succeeds, and isAdmin is dropped. Your code moves on, never knowing the client sent a field it had no business sending. Whether that’s an honest bug in the form 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: fine for a shape you trust, dangerous for a request body. The extra key disappears with no signal. For an input you constructed yourself this is harmless tidying. For anything off the wire it hides a contract mismatch.

z.strictObject is the one to internalize. For a Server Action input or an API request body it’s the senior default, because that’s the boundary where an unexpected key means something is wrong and you’d rather find out. z.looseObject is the rare opposite: you’re at the seam of an external system, its payload isn’t fully documented, and you deliberately want extras to pass through.

One note for when you read older code. Before Zod 4, these were method chains: z.object({...}).strict() and z.object({...}).passthrough(). You’ll meet those in existing codebases. In Zod 4 the top-level z.strictObject and z.looseObject builders are the canonical form, and they’re what you’ll write from here on.

The decision matters more than the syntax. The syntax is three names, but the durable skill is knowing which boundary gets which mode. Sort these boundaries into the mode each one calls for.

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

Two builders cover collections, and the choice between them is small but real.

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 inline: a tags field that needs at least one entry and at most a hundred is z.array(z.string()).min(1).max(100).

z.tuple is the other shape: 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]

The thing to get right is when each one fits. z.tuple is for data where length is fixed and position carries meaning, like a coordinate pair or a parsed CSV row. It is not “an array whose elements might be one of two types.” If you want a list of values that are each a string or a number, that’s z.array(z.union([z.string(), z.number()])), a uniform array of a union rather than a tuple. The common slip is reaching for z.tuple when you mean “a heterogeneous array,” so watch for that distinction.

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 where the finite-domain discipline you learned for TypeScript types becomes a runtime contract, and where one more senior decision appears.

The atom is z.literal. z.literal('paid') accepts only the exact string 'paid' and infers as the singleton type 'paid', that one value rather than string. It’s useful alone, but the real tool is built from it.

z.enum(['draft', 'sent', 'paid', 'overdue']) is the senior reach 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 something a hand-written union can’t: 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 does z.enum win 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')]). The two give the same validation and the same inferred type, but the enum form is shorter to write, faster to check, and it hands you that .enum accessor for free. Once you know they’re equivalent, you can use the enum form and never write the long version again. The course pairs literal-union TypeScript types with z.enum schemas deliberately: they are the same finite domain expressed on the type side and the runtime side, and z.infer is the bridge from one to the other.

There’s one sharp edge here worth getting exactly right, because it trips people up. When you pass the array of values inline, as above, Zod reads the literal strings directly and the inferred type is the narrow union, with no extra annotation needed. But if you hoist the array to a variable first, TypeScript widens it to string[] before z.enum ever sees it, so your enum collapses to validating any string. The fix is as const on the variable, which freezes it 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

So the rule is simple: pass the literal array inline, or if you must hoist it, write as const. Either way the goal is the same, to keep the literals narrow so the enum stays a real finite domain instead of decaying into string.

Tie this back to the type side. Earlier in the course you wrote type Status = 'draft' | 'sent' | 'paid' | 'overdue' as the disciplined way to type a finite set of legal values. z.enum(['draft', 'sent', 'paid', 'overdue']) is that same set expressed as a runtime contract, and z.infer of it gives you the exact union back. The type was always the goal, and the enum is how you also enforce it at the boundary.

One input, many shapes: unions and discriminated unions

Section titled “One input, many shapes: unions and discriminated unions”

Sometimes a single field isn’t one shape but one of several. A notification payload might be an email, with a to address, or an SMS, with a phone number. The two shapes share nothing but the fact that they’re both notifications. This is where the builders so far start to compose, and it’s where the chapter’s main idea about validating variants lives.

The blunt tool is z.union. z.union([z.string(), z.number()]) accepts either and infers as string | number. Its honest use is shapeless alternatives, a value that is genuinely a string or a number with no further structure. You’ll reach for it rarely.

For tagged variants, shapes distinguished by a shared field whose value says which one you’re looking at, 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, 'kind', 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 use a discriminated union and not a plain union of those two objects? A plain z.union tries the input against each branch in turn and takes the first that fully matches. With many branches that’s slow, and when none match, it can’t tell you which branch you meant. It can only report that every branch failed, stacking up each one’s complaints. z.discriminatedUnion reads the discriminator first and routes straight to the single branch that claims it. The validation is faster, and the intent is written into the schema: these variants are distinguished by kind. The difference you feel day to day is a clearer error, because a failed discriminated parse points at the real problem in the branch you actually intended, instead of a wall of complaints from every branch.

You can see the difference rather than take it on faith. Both tabs below validate the same broken input, an email notification missing its to field, against the two framings.

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

Tries every branch, and on failure it can’t say which one you meant. The input clearly intended the email branch, but the union reports failures against email and sms, leaving you to figure out which mattered.

This is the senior reach for any tagged variant crossing a boundary, whether a request body, a webhook, or a notification payload. It also does more than improve error quality. Earlier in the course you learned to make illegal states unrepresentable in the type system. A discriminated union makes them unrepresentable and also unparseable: a value that mixes fields from two different branches can’t typecheck and won’t parse.

The exercise below puts that to work. It 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 so the impossible case is rejected by the runtime contract, and watch the ^? query turn the type into a proper tagged union as you do.

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 schemas at the edges: unknown and never

Section titled “Two schemas at the edges: unknown and never”

Two builders round out the catalog. You’ll meet them more often than you write them, so the goal here is to recognize them.

z.unknown() accepts anything and infers as unknown, the schema-layer form of “parse to unknown, then narrow” from earlier in the course. It’s the honest type for a payload whose outside you’ve checked but whose inside you haven’t validated yet, and it’s the placeholder a jsonb column gets until you give it a real shape (more on that near the end of this chapter). z.never() is the opposite: it accepts nothing and infers as never. It’s rare in application code, and you’ll see it mostly where a type proves it has handled every case, the same never corner you met in the type system.

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”

You now have all eight builders. The last thing to settle is where the schemas live and what they’re called, because the rest of this chapter, and a great deal of the code you write after it, assumes one convention.

Schemas live alongside the domain types they describe, in your /lib directory, in the same file as the type they produce. The naming has two halves, and it may differ from the instinct you bring in: the schema constant is camelCase, the inferred type alias is PascalCase, and the type sits directly below the schema. A canonical entity shape is invoiceSchema; an action-input shape that mirrors a mutation is createInvoiceSchema. The type alias is type Invoice = z.infer<typeof invoiceSchema>, written on the next line.

The reason for the convention is the whole point of this lesson, restated as a file layout. One declaration yields two things the rest of your code depends on, the schema for runtime validation and the type for compile-time checking. Putting them in the same file, one immediately under the other, means 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. It brings the whole catalog together in one shape.

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 real email-format builder is the next lesson’s business), a z.number().int().positive() for the quantity, a z.enum for the finite status domain, and a z.array(z.string()) with length bounds for the tags. One z.object, four fields, and a single z.infer that hands the action its input type. This is the shape every consumer in the rest of the course imports.

Try it before moving on. The playground below is prefilled with this schema and three sample inputs: one valid invoice, one with a status outside the legal set, and one carrying an extra key so you can watch the default object mode quietly accept it. Change fields, add your own, and see which inputs the contract accepts against the live Zod runtime.

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