A multi-step application form usually ends up with three schemas that drift apart: the full schema for final submit, a hand-written “draft” schema that is looser, and per-step schemas copied from bits of the full one — until a rule changes in one place, and a user who completed step 2 is told at submit that step 2 is invalid.
Zod can derive all three from the full schema: .pick() for a step’s fields, .partial() for drafts, and .extend() or .merge() to recombine. This page builds that derivation so every rule lives in one place. It serves both validating only the current step and versioning and migrating saved draft schemas, within integrating Zod for schema validation.
Context and prerequisites
Three validation moments, three strictness levels:
- Draft save — accept anything the user has typed so far, but reject values that are wrong, not merely missing. An email field containing “ada@” is incomplete and can be saved; a field holding an object where a string belongs is corrupt and should not be.
- Step validation — the current step’s fields must be complete and valid; other steps are ignored.
- Final submit — the whole object must satisfy every rule, including cross-step refinements.
The derivation tools:
.pick({ a: true })/.omit({ b: true })— select fields from an object schema, keeping each field’s rules..partial()— make every field optional (shallow)..partial({ a: true })for specific fields..required()— the inverse, for fields that were optional..extend()/.merge()— add or combine shapes.
The catch: refinements attached with .refine/.superRefine on an object are not carried through .pick or .partial, because those methods need the plain object shape. Keep object-level refinements separate and apply them where they belong.
The core pattern: base shape, derived schemas, refinements kept apart
import { z } from "zod";
// 1. Base shape: every field with its per-field rules. NO .refine() here,
// so .pick() and .partial() remain available.
export const ApplicationBase = z.object({
fullName: z.string().trim().min(1, "Enter your full name."),
email: z.string().trim().email("Enter an email like [email protected]."),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Enter a start date."),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Enter an end date."),
employer: z.string().trim().min(1, "Enter your employer."),
salary: z.number().int().positive("Enter your salary."),
});
// 2. Reusable refinements, written against the fields they need.
const datesInOrder = <T extends { startDate?: string; endDate?: string }>(v: T, ctx: z.RefinementCtx) => {
if (v.startDate && v.endDate && v.endDate < v.startDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["endDate"], message: "End date must be after the start date." });
}
};
// 3. Steps pick their fields and add only the refinements that are local to them.
export const steps = {
about: ApplicationBase.pick({ fullName: true, email: true }),
dates: ApplicationBase.pick({ startDate: true, endDate: true }).superRefine(datesInOrder),
employment: ApplicationBase.pick({ employer: true, salary: true }),
} as const;
// 4. Draft: every field optional, but anything present must be the right type.
// Format rules are relaxed so half-typed values can be saved.
export const ApplicationDraft = z.object({
fullName: z.string(),
email: z.string(), // "ada@" is fine in a draft
startDate: z.string(),
endDate: z.string(),
employer: z.string(),
salary: z.number(),
}).partial().strip();
// 5. Final submit: the full base plus every cross-step refinement.
export const ApplicationSubmit = ApplicationBase.superRefine(datesInOrder);
export type Application = z.infer<typeof ApplicationSubmit>;
Step-by-step walkthrough
- Define a base object with per-field rules and no object-level refinements. This keeps
.pick,.omitand.partialavailable — refined schemas areZodEffectsand lose them. - Write refinements as standalone functions. A function taking the value and
ctxcan be attached to a step schema and to the submit schema without duplication. - Derive step schemas with
.pick. Each step validates exactly the base’s rules for its fields, then adds refinements whose fields all live on that step. - Decide what “draft” means. Usually: types must match, formats need not. That is a different schema from
base.partial()(which would still reject “ada@”), so derive it deliberately — here, the same keys with type-only rules. - Attach cross-step refinements only at submit. A rule comparing a step-1 field with a step-3 field cannot run on either step alone; it belongs on the full schema, and its error should route the user back to the right step, as in building a review step before final submit.
- Strip unknown keys in drafts.
.strip()removes fields that no longer exist, which matters when old drafts are restored after a release.
Why “partial” is not the same as “draft”
ApplicationBase.partial() makes fields optional but keeps every rule on the fields that are present. For drafts, that is often too strict: a user halfway through typing an email has "ada@", which fails .email(), so an autosave would be rejected at exactly the moment it is most useful. A draft schema answers a different question — “is this safe to store and restore?” — not “is this a valid answer?” Types must be right, so a restored draft cannot crash the form; formats and minimums can wait for step or submit validation. Keeping the two concepts separate avoids autosave failures that users never see but that silently lose their work.
Failure modes and edge cases
1. .pick is not a function
Calling .pick on a schema that has .refine attached fails, because the result is an effects wrapper, not an object schema. Keep refinements off the base and apply them to derived schemas.
2. Nested objects and .partial()
.partial() is shallow: an optional address object still requires its own fields when present. For drafts with nested objects, build the draft shape explicitly or apply .partial() at each level. (Zod 3’s .deepPartial() is deprecated; do not rely on it.)
3. Step schemas out of sync with step UI
If the step schema picks email but the step’s UI no longer renders it, the user is blocked by an invisible field. Derive both from one step definition — a list of field names used for rendering and for .pick.
4. Discriminated unions
Conditional sections modelled with z.discriminatedUnion cannot be .picked across branches. Pick per branch, or validate the step against the union after merging defaults, as in discriminated unions for conditional schemas.
5. Type inference for drafts
z.infer<typeof ApplicationDraft> has every field optional, which is right for draft storage. Do not use the draft type for the submit payload; the submit type is z.infer<typeof ApplicationSubmit>.
Verification checklist
Frequently Asked Questions
Why not just validate the whole form on every step and filter errors?
It works for small forms, and it is a reasonable shortcut. It becomes costly with async refinements or large schemas, and error filtering by step needs its own mapping. Picking a step schema is clearer and cheaper.
Can the draft schema reuse base rules at all?
Yes, selectively. Rules that should hold even for drafts — a maximum length that protects storage, an enum value that must be one of the options — can be copied into the draft schema by picking from the base for those fields and merging with the relaxed ones.
How does this work in Zod 4?
The same methods exist on object schemas (pick, omit, partial, extend), and refinements still need to be kept off the base to preserve them. Check your version’s release notes for changes to merge and to how refinements compose.