Zod schemas: optional vs nullable vs default, z.infer and Zod 4
What .optional(), .nullable() and .default() each mean in a Zod object schema, what z.infer derives from it, and which spellings changed in Zod 4.
One declaration, two jobs
A Zod schema is a value you write once and use twice. At runtime it is a parser: hand it unknown input — a request body, a config file, a model's tool call — and it either returns a value of a known shape or tells you exactly what is wrong. At compile time it is a type: z.infer<typeof schema> is the TypeScript type of what the parser returns, derived from the schema rather than declared beside it.
import { z } from "zod"
export const userSchema = z.object({
id: z.uuid(),
email: z.email(),
name: z.string().min(1).max(80),
age: z.number().int().min(0).optional(),
role: z.enum(["admin", "member", "guest"]).default("member"),
tags: z.array(z.string()).default([]),
createdAt: z.date(),
})
export type User = z.infer<typeof userSchema>
const user = userSchema.parse(input) // User, or a thrown ZodError
That is the whole idea, and the reason Zod displaced hand-written interface + validate() pairs: the two can never disagree, because there is only one of them. Everything below is about the decisions inside that z.object({ … }) — and the free Zod schema builder is a form for making them, which emits exactly this file with the inferred type written out beside it.
Zod optional vs nullable vs default
These are the three modifiers people mix up, and they answer different questions about a key. .optional() is about the key, .nullable() is about the value, and .default() is about what comes out when the key was missing.
| Modifier | Key may be absent? | Value may be null? | Inferred (output) type |
|---|---|---|---|
.optional() | yes | no | age?: number | undefined |
.nullable() | no | yes | nickname: string | null |
.nullish() | yes | yes | nickname?: string | null | undefined |
.default(v) | yes | no | role: "admin" | "member" — required |
.optional() — may the key be absent? The inferred type gets a ?:
age: z.number().optional()
// age?: number | undefined
.nullable() — must the key be present, but may its value be null? The type becomes a union:
nickname: z.string().nullable()
// nickname: string | null
.default(value) — may the key be absent on input, and is it filled in on output? This is the one whose type surprises people:
role: z.enum(["admin", "member"]).default("member")
// role: "admin" | "member" — required in the OUTPUT type
A defaulted field is optional on the way in and guaranteed on the way out, so z.infer — which is the output type — shows it as required. If you also chain .optional() you get nothing extra; the builder emits .default() alone when both are set, so the inferred type reads the way the parser behaves.
.optional().nullable() and .nullish()
Combinations are fine and the order is conventional: constraints first, then .describe(), then .nullable(), then .optional() or .default():
nickname: z.string().min(2).describe("Shown in place of the name.").nullable().optional()
// nickname?: string | null | undefined
Chaining both accepts three inputs — the string, null, and an absent key. .nullish() is the same thing in one call, and the two are interchangeable:
z.string().nullable().optional() // string | null | undefined
z.string().optional().nullable() // string | null | undefined
z.string().nullish() // string | null | undefined — identical
Order does not matter between those two, because each widens the accepted set and neither narrows it back. That is not true of .default(), which is order-sensitive: .optional().default("x") fills in "x" for a missing key, while .default("x").optional() re-widens the output type to include undefined and undoes the point of the default.
Which one should you use?
The right modifier follows from where the data comes from, not from taste:
- A JSON request body for a partial update —
.optional(). The client omits the fields it is not changing, and an absent key is the signal. - A SQL row read through a driver —
.nullable(). A nullable column comes back as an explicitnull, never as a missing key, so.optional()would reject exactly the rows it is meant to accept. - A field that is genuinely three-state —
.nullish(). "Not sent", "explicitly cleared", and "set to a value" are three different intents, and collapsing the first two loses the ability to distinguish clear this from leave it alone. - A setting with a sensible fallback —
.default(). The parser fills it in, so nothing downstream needs a?? fallbackand the output type stays required.
The most common mistake is reaching for .optional() on database-shaped data. It type-checks, it looks right, and it throws on the first row with a NULL in it.
Strings: formats and bounds
z.string() takes length bounds and a regex:
slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/)
The common formats are their own schemas in Zod 4 — z.email(), z.url(), z.uuid(), z.iso.datetime() — and they are string schemas, so .min(), .max() and .describe() chain onto them as usual. Zod 3 spelled these as methods on z.string(): z.string().email(). Those methods still exist in Zod 4 and still work, but they are deprecated, and new code should use the top-level forms.
A regex is the one constraint worth validating before you ship it. A pattern that does not compile throws when the schema module is imported, not when something is parsed — which means at app start, in every environment at once. The builder refuses a pattern new RegExp() rejects and reports it beside the field rather than emitting it.
Numbers, enums, literals
quantity: z.number().int().min(1).max(999)
status: z.enum(["draft", "published", "archived"])
version: z.literal(2)
.int() rejects fractions. z.enum takes a tuple of strings and infers their union; a default for an enum field must be one of its members, and the builder checks that. z.literal takes one value — a string, number or boolean — and infers that exact value as the type, which is how you discriminate a union of shapes (z.discriminatedUnion("type", [...])) when the time comes.
Arrays, records, nested objects
tags: z.array(z.string()).max(10)
scores: z.record(z.string(), z.number())
address: z.object({
street: z.string(),
city: z.string(),
postcode: z.string().regex(/^[A-Z0-9 ]{3,10}$/),
}).optional()
items: z.array(z.object({ sku: z.string(), qty: z.number().int() }))
z.array takes the element schema and its own length bounds. z.record takes the key schema and the value schema — Zod 4 requires both arguments, and the two-argument form is also valid in Zod 3, so it is the one to write. A nested z.object is just another schema; .optional() on it makes the whole sub-object absent-able, and the inferred type nests accordingly:
address?: {
street: string
city: string
postcode: string
}
Unknown keys: strip, strict, loose
By default z.object strips keys the schema does not declare — they are silently dropped from the output. That is right for most API bodies. For a config file, where an unexpected key is probably a typo, you want the parse to fail instead; for a pass-through envelope you want the extra keys kept:
// Zod 4
z.strictObject({ … }) // unknown keys are an error
z.looseObject({ … }) // unknown keys are kept
// Zod 3 (still works in 4, deprecated)
z.object({ … }).strict()
z.object({ … }).passthrough()
.describe() is for two readers
.describe("…") attaches a description that shows up in editor hovers and in generated documentation. It also survives into JSON Schema:
z.toJSONSchema(userSchema)
// { type: "object", properties: { email: { type: "string", format: "email", description: "…" } }, … }
That matters because a Zod object is what a model reads before it calls a tool. The MCP server generator turns a Zod shape into a tool's inputSchema; the Claude Agent SDK and OpenAI Agents SDK generators type their tools the same way. To the model, a field with no description is a field it has to guess at, so describe every field that a model will ever see.
Reading errors
.parse() throws a ZodError; .safeParse() returns { success, data | error } and never throws, which is the form to use at a boundary:
const result = userSchema.safeParse(body)
if (!result.success) {
console.error(z.prettifyError(result.error))
// ✖ Invalid UUID
// → at id
// ✖ Too small: expected string to have >=1 characters
// → at name
}
Zod 4 added z.prettifyError, z.treeifyError and z.flattenError as top-level functions; pick the one whose shape your error UI wants.
Zod 4 vs Zod 3, in one list
Each line is Zod 4 first, Zod 3 second.
- An email string —
z.email()·z.string().email() - A UUID —
z.uuid()·z.string().uuid() - An ISO datetime —
z.iso.datetime()·z.string().datetime() - Reject unknown keys —
z.strictObject({})·z.object({}).strict() - Keep unknown keys —
z.looseObject({})·z.object({}).passthrough() - A record —
z.record(z.string(), v)·z.record(v), though the two-argument form works there too - JSON Schema —
z.toJSONSchema(s)· a third-party package - Readable errors —
z.prettifyError(e)·e.format()/e.flatten()
.min, .max, .regex, .describe, .optional, .nullable, .default, z.enum, z.literal, z.array and z.infer are the same in both. The builder has a version switch that flips only the lines that differ. If you are moving an existing codebase across rather than starting fresh, the Zod 4 migration guide walks the same changes in the order worth doing them in.
Where to start
npm install zod, one z.object, and export type X = z.infer<typeof xSchema> beneath it. If you would rather see the whole file first — formats, bounds, enums, nested objects and defaults, with the inferred type on a second tab and every inconsistent field reported rather than repaired — the Zod schema builder emits it, free and without an account.
Target-specific scaffolding
Continue this learning path
Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.
Design your application
Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.
Open projects