Derive, don't duplicate
Reshape one canonical Zod schema into every boundary variant your app needs with .pick, .omit, .extend, .partial, and the z.input/z.output type split.
You have a userSchema: one canonical declaration of what a user is, with an email, a passwordHash, a name, and an avatarUrl. It parses a user, it infers the User type, and so far one schema has been enough.
Then a single sprint lands three tickets on it. The sign-up form takes an email, a password, and a name. That’s a subset of the user, and not even the same password field, since the form holds a plaintext password where the row holds a hash. The profile-edit form takes a name and an avatarUrl, a different subset. The public profile endpoint must return the whole user except the password hash, because leaking a password hash over an API is the kind of mistake that ends up in an incident write-up. That’s three boundaries and three shapes, each one a reshaped view of the same user.
You have a real choice here. You can write three fresh z.objects, createUserSchema, updateUserSchema, and publicUserSchema, each one spelled out by hand and each one repeating most of userSchema. Or you can declare the user once and derive the other three from it. This lesson covers why an experienced engineer takes the second path, and the small set of methods that make deriving as quick to write as the copy would have been.
A few methods carry the whole approach: .pick and .omit to narrow a schema, .extend and an object spread to grow one, and .partial, .required, and .readonly to flip its modifiers. Because a schema’s type comes along for free, a second idea waits at the end of the lesson. Once a schema carries a .transform, the type it accepts and the type it returns stop being the same, and you’ll need z.input and z.output to tell them apart.
You already have the intuition for all of this from the TypeScript chapter, where you reshaped types with Pick, Omit, and Partial, operators that take a type and hand back a new one. Zod gives you the runtime twins of those operators, and because the type is inferred from the schema, reshaping the schema reshapes the type for free. That is the whole lesson in one sentence, and everything below works out what follows from it.
The drift bug derivation prevents
Section titled “The drift bug derivation prevents”Start with the problem derivation solves. Here are the three schemas the sprint asked for, written the obvious way, by hand. In the first tab, notice how much of it is the same words typed three times.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});type User = z.infer<typeof userSchema>;
const createUserSchema = z.object({ email: z.email(), password: z.string().min(8), name: z.string(),});type CreateUser = z.infer<typeof createUserSchema>;
const updateUserSchema = z.object({ name: z.string(), avatarUrl: z.url(),});type UpdateUser = z.infer<typeof updateUserSchema>;
const publicUserSchema = z.object({ email: z.email(), name: z.string(), avatarUrl: z.url(),});type PublicUser = z.infer<typeof publicUserSchema>;Looks fine today. It parses, the types are right, it ships. The trouble shows up later. Six months from now a currency column joins the user, and now four separate places have to learn about it. If you update three and forget the fourth, that one boundary silently rejects valid input or leaks a field you meant to drop. Nothing fails loudly, and a git grep plus your memory are the only things holding the four copies in agreement.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});type User = z.infer<typeof userSchema>;
const publicUserSchema = userSchema.omit({ passwordHash: true });type PublicUser = z.infer<typeof publicUserSchema>;
const createUserSchema = userSchema .pick({ email: true, name: true }) .extend({ password: z.string().min(8) });type CreateUser = z.infer<typeof createUserSchema>;
const updateUserSchema = userSchema.pick({ name: true, avatarUrl: true });type UpdateUser = z.infer<typeof updateUserSchema>;One edit flows everywhere. Add currency to userSchema and the public response picks it up automatically. The create and update shapes deliberately don’t, because their .pick and .omit masks never named it. The variants can’t drift for the simplest reason: they don’t independently exist. There is one source, and three views onto it.
The second tab is the principle: derive, don’t duplicate. Declare one canonical schema, derive every variant from it, and let each derived schema’s name carry its role, so that createUserSchema and publicUserSchema state their intent right in the name. Drift stops being something you catch in code review and becomes something that can’t happen by construction, because there’s nothing left to drift from.
This is the schema-layer version of a principle the database chapter already taught you: the schema is the source of truth. There, db/schema.ts was the one place a column was defined, and everything else, row types and inserts, flowed from it. Here it’s the same idea one layer up: one schema, every boundary derived from it.
The failure mode this avoids is drift : two copies of a truth, one of them edited and the other quietly left behind. Derivation removes drift the only reliable way, by making sure there’s only ever one copy.
Narrowing: .pick and .omit
Section titled “Narrowing: .pick and .omit”Start with the two methods you’ll reach for most, because they cover the two most common boundaries in any app: a response that must hide a field, and a form that edits a subset of the entity.
.pick keeps the keys you name and drops the rest. You hand it the keys you want to keep:
const createUserSchema = userSchema.pick({ email: true, name: true });type CreateUser = z.infer<typeof createUserSchema>;// { email: string; name: string }.omit is the mirror: keep everything except the keys you name. This is the method that makes the public-response shape, and it’s worth dwelling on, because getting it wrong is how a password hash leaks over the API:
const publicUserSchema = userSchema.omit({ passwordHash: true });type PublicUser = z.infer<typeof publicUserSchema>;// { email: string; name: string; avatarUrl: string }publicUserSchema now can’t describe a password hash at all. The field isn’t in its shape, so the inferred PublicUser type doesn’t have it, so an endpoint that returns a PublicUser can’t leak it without a type error first. The compiler enforces the omission, not your memory.
Two details trip people up. First, notice the argument shape: { email: true, name: true }, not ['email', 'name']. It’s a mask, an object keyed by field name with true as the value, not a list. If your fingers want to type an array, that’s the habit to retrain. Second, .pick and .omit both return a brand-new object schema that inherits the source’s strictness mode. If userSchema were a z.strictObject, which rejects unknown keys, every schema derived from it would reject them too, because the mode rides along with the shape. The default z.object strips unknown keys, so that’s the behavior the derived schemas in this lesson have unless you change the source.
That last point has a consequence worth pinning down, because it surprises people. When you .omit a key, you remove it from the schema’s shape, but you don’t add a rule that rejects an input still carrying that key. With the default z.object, an input that includes passwordHash still parses cleanly: the key is simply stripped from the output, the way every unknown key is. .omit changes what the schema describes and returns, not what it tolerates on the way in. Try it in the exercise below.
Derive `publicUserSchema` from the given `userSchema` by omitting `passwordHash`. Watch the `^?` query — once you do, `passwordHash` disappears from the inferred type. Read the first fixture closely: an input that still *carries* a password hash passes, because the default object mode strips the extra key rather than rejecting it. `.omit` removes the field from the shape; it does not add a guard against the field arriving.
| Test scenario | Value | |
|---|---|---|
| full user, with hash | {"email":"ada@example.com","passwordHash":"$2b$x","name":… | |
| no hash | {"email":"ada@example.com","name":"Ada","avatarUrl":"http… | |
| missing email | {"name":"Ada","avatarUrl":"https://x.test/a.png"} | |
The answer is userSchema.omit({ passwordHash: true }), a mask ({ key: true }), not a list. The first fixture is the one to dwell on: it ships a passwordHash, yet it passes, because .omit on a default z.object removes the field from the shape and strips any extra key on the way in rather than rejecting it. To reject the input for carrying that key, the base would have to be a z.strictObject, but for a public-response shape, stripping is exactly the behavior you want.
Adding and combining: .extend and the spread merge
Section titled “Adding and combining: .extend and the spread merge”Narrowing has an inverse: sometimes a boundary needs more than the base entity carries. The classic case is a form field that exists only in the UI and never in the database, like a confirmPassword the user types twice or a terms checkbox they tick. The base userSchema has no business knowing about those fields, but the sign-up form schema does.
.extend adds fields to an object schema. Hand it a shape, and you get back a new schema with those keys grafted on:
const signupFormSchema = userSchema.extend({ confirmPassword: z.string(),});There’s a second use for .extend worth knowing: if the shape you pass reuses an existing key, the new definition wins. userSchema.extend({ name: z.string().max(50) }) keeps every other field exactly as it was and tightens just name. That’s the clean way to override one field of a derived schema without re-declaring the rest.
So .extend is the right tool for tacking a few fields onto one base. A different job is fusing two schemas you’ve already named, say a userSchema and a separate billingFieldsSchema, into one object that has every field of both. Your instinct from older Zod might be to reach for .merge, and you will meet .merge in existing codebases. In Zod 4 it’s the wrong reach:
const accountSchema = userSchema.merge(billingFieldsSchema);const accountSchema = z.object({ ...userSchema.shape, ...billingFieldsSchema.shape });.merge is deprecated in v4, and .extend carries a real cost when you chain a lot of it: its type machinery gets quadratically expensive and can slow your type-checker on large schemas. The replacement is plainer than either: spread both schemas’ .shape into a fresh z.object. Every object schema exposes its field map as .shape, so { ...userSchema.shape, ...billingFieldsSchema.shape } is just an object spread, the same language feature you’d use to merge any two objects, with no Zod-specific method at all. It’s faster for the type-checker, it works identically in zod/mini, and on a key collision the rule is the one you already know: last spread wins.
The decision comes down to two cases. Adding a handful of fields to one base is .extend, and fusing two named schemas is the spread. Don’t reach for .merge; when you see it in old code, that’s your cue it predates Zod 4.
Now watch the methods compose, because that’s where the payoff compounds. The sign-up schema isn’t just a narrowing or just an addition; it’s both. Pick the editable subset of the user, then extend it with the UI-only confirmation field:
const signupSchema = userSchema .pick({ email: true, name: true }) .extend({ password: z.string().min(8), confirmPassword: z.string(), });That’s one declaration of signupSchema, derived in two moves from the canonical user. The cross-field rule from the last lesson, the .refine that checks password === confirmPassword, belongs right here on this derived schema, because this is the only shape where both fields coexist. Deriving first and refining second is the natural order, and that order matters again in a moment.
Modifier flips: .partial, .required, .readonly
Section titled “Modifier flips: .partial, .required, .readonly”The third family doesn’t change which fields a schema has; it changes how those fields behave. This is the family where the TypeScript connection pays off most directly: .partial is Partial, .required is Required, and .readonly is Readonly, the same operators you ran on types in the TypeScript chapter, except these are backed by a runtime check rather than just a compile-time reshuffle.
Start with .partial, because it answers a question every CRUD app asks: what’s the shape of a PATCH? A partial update sends some fields and leaves the rest untouched, so its schema is the entity with every field optional. That’s exactly what .partial() produces:
const updateInvoiceSchema = invoiceSchema.partial();const draftInvoiceSchema = invoiceSchema.partial({ tags: true });With no argument, .partial() makes every field optional, so updateInvoiceSchema is the everyday PATCH shape, the entity with a ? on every key. With a mask ({ tags: true }, the same { key: true } shape as .pick), only the named field goes optional and the rest stay required, so draftInvoiceSchema lets you save a draft without tags while still demanding the others. The 2026 move is to derive your update schema this way every time, with invoiceSchema.partial(), rather than hand-writing a twin where every field happens to have a ? on it. (Later in the chapter you’ll see that the canonical schema itself is often generated from the database table, and you derive the partial from that, the same move one layer further back.)
.required() is the inverse: it forces optional fields to be present, for the rarer case where a schema has optionals you want to make mandatory in a particular context. .readonly() does something the type-only Readonly from the TypeScript chapter does not:
const frozenUserSchema = userSchema.readonly();const user = frozenUserSchema.parse(input);
user.name = 'Grace'; // TypeScript error — and throws at runtime: the object is frozen.readonly() infers as Readonly<T>, marking every field read-only to the type-checker, but it also runs Object.freeze() on the parsed result. So a later user.name = '…' doesn’t just fail to compile; it throws at runtime (or no-ops silently in loose code). That’s the difference from the type-only Readonly operator you met before: this one is a real guard enforced by the JavaScript engine, not just a promise the compiler tracks. You reach for it in the cache and read layer much later in the course. When a single object is shared out of a cache to many consumers, freezing it means no consumer can mutate the copy everyone else is holding. So .readonly belongs in this family after all: like the others it changes both the type and the runtime, except its runtime change is freezing the value rather than reshaping the fields.
One mistake lives right at the seam between this family and the last lesson’s refinements:
Now make the PATCH shape concrete yourself. Turn the invoice into its update schema with .partial(), and watch every key in the ^? query gain a ?.
Derive `updateInvoiceSchema` from `invoiceSchema` with `.partial()`. Watch the `^?` query — every field gains a `?`. The fixtures prove the PATCH shape: a full body passes, a single field passes, even an empty body passes — but a wrong *type* still fails, because optional means 'may be absent', not 'anything goes'.
| Test scenario | Value | |
|---|---|---|
| full body | {"email":"ada@example.com","quantity":3,"status":"sent","… | |
| single field | {"status":"paid"} | |
| empty body | {} | |
| wrong type | {"quantity":"lots"} | |
The family of derivations, at a glance
Section titled “The family of derivations, at a glance”Step back and look at the set together. You now have a small algebra: take a schema, narrow it, grow it, or flip its modifiers, and get back a new schema, and a new type, every time. Here’s the whole family operating on one userSchema, so you can see a single source produce every boundary shape the app needs.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});
const createUserSchema = userSchema.pick({ email: true, name: true });
const publicUserSchema = userSchema.omit({ passwordHash: true });
const signupSchema = userSchema .pick({ email: true, name: true }) .extend({ confirmPassword: z.string() });
const updateUserSchema = userSchema.partial();The one canonical source: four fields, defined once. Every shape below is a view onto this schema, so changing a field here updates the views downstream.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});
const createUserSchema = userSchema.pick({ email: true, name: true });
const publicUserSchema = userSchema.omit({ passwordHash: true });
const signupSchema = userSchema .pick({ email: true, name: true }) .extend({ confirmPassword: z.string() });
const updateUserSchema = userSchema.partial();.pick narrows: keep email and name, drop the rest. This is the create-input shape, the editable subset a form submits.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});
const createUserSchema = userSchema.pick({ email: true, name: true });
const publicUserSchema = userSchema.omit({ passwordHash: true });
const signupSchema = userSchema .pick({ email: true, name: true }) .extend({ confirmPassword: z.string() });
const updateUserSchema = userSchema.partial();.omit narrows the other way: keep everything except the secret. This is the public-response shape, provably free of the password hash.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});
const createUserSchema = userSchema.pick({ email: true, name: true });
const publicUserSchema = userSchema.omit({ passwordHash: true });
const signupSchema = userSchema .pick({ email: true, name: true }) .extend({ confirmPassword: z.string() });
const updateUserSchema = userSchema.partial();Narrow and grow: pick the subset, then add the UI-only confirmPassword. Two moves compose into one derived schema.
const userSchema = z.object({ email: z.email(), passwordHash: z.string(), name: z.string(), avatarUrl: z.url(),});
const createUserSchema = userSchema.pick({ email: true, name: true });
const publicUserSchema = userSchema.omit({ passwordHash: true });
const signupSchema = userSchema .pick({ email: true, name: true }) .extend({ confirmPassword: z.string() });
const updateUserSchema = userSchema.partial();.partial flips modifiers: every field optional, which is the PATCH body shape. Four derivations, one source, zero copies to keep in sync.
Drawn out, that’s one box on the left and four arrows fanning to the right, each labeled with the method that produced it. This is the picture to keep in your head: not five schemas you maintain by hand, but one schema and four contracts derived from it.
userSchema
canonical source
createUserSchema
create-input
publicUserSchema
public response
signupSchema
signup form
updateUserSchema
PATCH body
userSchema and every arrow carries it downstream — or deliberately doesn't, where a .pick or .omit mask leaves it out.
Two specialist builders: z.record and z.intersection
Section titled “Two specialist builders: z.record and z.intersection”Most derivation is object-reshaping: pick, omit, extend, partial. Two more composition tools aren’t about reshaping an object’s keys at all. They show up less often, but each carries a Zod 4 gotcha worth knowing in advance.
z.record describes a map: an object whose keys aren’t known ahead of time but whose values all share one shape. Think of feature flags keyed by an arbitrary flag name, a locale dictionary, or the open-ended metadata blob on an entity. In Zod 4, z.record takes two arguments, the key schema and the value schema:
const flags = z.record(z.boolean());const flags = z.record(z.string(), z.boolean());// → Record<string, boolean>The struck line is the v3 form, a single argument with the value schema alone, and it’s gone in v4. It doesn’t compile, and it’s exactly the kind of thing you’ll hit pasting an old snippet or taking an AI’s first suggestion. The v4 form below it names the key schema first, then the value, and infers as Record<string, boolean>. Always name the key schema. Two things follow from naming it. By default z.record now rejects keys that don’t match the key schema (the pass-through-unmatched variant is z.looseRecord). And if you narrow the key to a z.enum([...]), Zod will check that every enum value is present, a small but handy exhaustiveness guarantee.
z.intersection describes a value that must satisfy two schemas at once, an intersection , the & to a union’s |. For two object schemas you’ve already got the better tool: the spread merge from earlier is clearer and cheaper, so don’t reach for intersection on objects. Intersection earns its place for the genuine non-object case, a primitive that must clear two separate refined schemas:
const evenPositive = z.intersection( z.number().positive(), z.number().refine((n) => n % 2 === 0, 'must be even'),);So the decision rule is this: for two object shapes you want fused, spread { ...a.shape, ...b.shape }; for anything else that has to satisfy two schemas, use z.intersection. One adjacent mistake to avoid: if what you actually want is an object that preserves unknown keys after parsing, that’s z.looseObject from the first lesson, not a z.record wrapped around it. The two do different jobs. z.looseObject keeps the extras on a known shape, while z.record describes a shape that’s all open keys.
When transforms split the type: z.infer, z.input, z.output
Section titled “When transforms split the type: z.infer, z.input, z.output”One last idea, and it’s the one people most often get wrong in production, because the wrong choice doesn’t fail at the schema. It surfaces later, as a confusing type error in code that looks innocent.
Start from the default. For a plain schema, a z.object with ordinary fields and no transforms, the type the parser accepts and the type it returns are the same shape. A { name: string } goes in, a { name: string } comes out. So z.infer<typeof schema> is the only inference helper you ever need, and most of your schemas live happily here. Keep that as the baseline.
Now the case that breaks it. The last lesson taught you that a .transform moves the inferred type; you watched z.iso.datetime().transform((s) => new Date(s)) flip the ^? query from string to Date. That movement has a consequence we passed over at the time: once a transform is in the chain, the parser accepts a string but returns a Date. Same schema, two different types, an input type and an output type that no longer agree. So a single z.infer isn’t enough anymore, and you need a way to name each end:
z.input<typeof schema>is the type the parser accepts, the pre-transform shape. For our date field, that’sstring.z.output<typeof schema>is the type the parser returns, the post-transform shape. That’sDate.z.infer<typeof schema>resolves to the output type. It gives you the same thing asz.output, the parsed shape, soz.inferalways means the output side.
This isn’t academic; it’s the exact seam where a Server Action’s form input lives. Picture the invoice’s issuedAt field, validated as an ISO string and then transformed to a Date with z.iso.datetime().transform((s) => new Date(s)), so a string comes in and a Date comes out. The form on the page sends a string, because form data is string-only, so the form’s contract is z.input, which resolves to string. After parsing, the action body holds a real Date, so the action’s parameter type is z.output, which is the same as z.infer. That’s two helpers for the two ends of one schema, and using the wrong one is the bug. Type a form helper with z.infer and you’ve told it it’ll receive a Date, when what actually arrives is the pre-transform string.
See both types at once. The schema below validates issuedAt as an ISO string, then transforms it to a Date. Fill in the two type aliases so the ^? rows resolve correctly, with FormInput showing issuedAt as string and Parsed showing it as Date, and watch the string fixture parse against the real runtime.
This schema validates `issuedAt` as an ISO string, then transforms it into a `Date`. Fix the two type aliases so each `^?` resolves correctly: `FormInput` is what the form *sends* — set it with `z.input`, and the query lands on `string`. `Parsed` is what the action *receives* — set it with `z.output` (which equals `z.infer`), and the query lands on `Date`. The fixtures prove the split: a string goes in and the parse succeeds, because the form contract is the string side.
| Test scenario | Value | |
|---|---|---|
| string in (form sends a string) | {"issuedAt":"2026-03-01T00:00:00Z"} | |
| non-date string | {"issuedAt":"not a date"} | |
.describe: the schema’s documentation channel
Section titled “.describe: the schema’s documentation channel”A schema can also carry prose. .describe() attaches a human-readable note to a schema or a single field:
const nameField = z.string().describe('User-facing display name, NFC-normalized');That string isn’t decoration; consuming tools read it. An OpenAPI generator surfaces it as the field’s description, drizzle-zod carries it through, and documentation pipelines pick it up. When your codebase ships a generated API reference, .describe is where the field’s prose lives, one source feeding every surface. That’s the same derive-don’t-duplicate idea applied to documentation instead of shape. (Much later, when you give tools to an LLM, those tool input schemas lean on .describe heavily, because the model reads it to know what each field means. It’s named here, and you’ll use it for real then.)
Self-referential shapes: the lazy getter
Section titled “Self-referential shapes: the lazy getter”One edge case, named so you recognize it when it appears. Some shapes reference themselves: a comment with replies that are comments, a folder that contains folders. A schema can’t reference its own const while that const is still being defined, so the naive version doesn’t work. Zod 4’s recommended pattern is a getter on the object shape:
const categorySchema = z.object({ name: z.string(), get children() { return z.array(categorySchema); },});The get children() getter defers evaluating categorySchema until the schema is actually used, so the self-reference resolves lazily with no z.lazy() wrapper and no type cast. (z.lazy(() => …) still exists for explicit or compatibility cases.) This is the tree-shaped-state case: uncommon in the flat CRUD entities most of a SaaS app is made of, but common the moment you model a true hierarchy.
Where to go deeper
Section titled “Where to go deeper”The Zod documentation has dedicated pages for both halves of this lesson: the object methods that do the deriving, and the inference helpers that name the input/output split.