wiki/optionality.md
Internal reference for how Zod's parsing handles "missing" / "undefined" input. Reflects the current state of main.
The system has accumulated a few orthogonal mechanisms. This doc names them, says which schema sets what, and walks through the gnarly interactions.
Two runtime signals:
| Signal | Set by | Consumed by | Means |
|---|---|---|---|
_zod.optin — undefined / "optional" / "defaulted" | catch, default, prefault, optional, transform | $ZodObject, $ZodTuple, $ZodOptional; also the JSON Schema emitter, where objectProcessor and tupleProcessor both read the static value — see below | A three-rung ladder: required / absence permitted / absence permitted and substituted for |
_zod.optout === "optional" | optional, exact-optional, default-on-output cases | $ZodObject, $ZodTuple | "My output may legitimately be undefined; treat that as absent for length-shortening / key-omission" |
| Plus one bookkeeping flag: |
| Signal | Set by | Consumed by | Means |
|---|---|---|---|
payload.aborted | pipe stage with issues, codecs | downstream stages | Skip remaining work in the chain |
Optionality has two independent axes:
Pre-4.4, Zod conflated these: for object property a, parsing {} and parsing { a: undefined } ran the same code path. Whatever the property schema produced for undefined input got assigned. This was unsound — schemas that statically said "key is required" would silently accept missing keys at runtime.
#5661 split the two axes. $ZodObject and $ZodTuple now consult optin to decide whether absent input is legal, before running the property schema at all.
optin"Can the parent container omit this slot, and does anything fill it?"
A static-and-runtime declaration on the schema's _zod. Three rungs, each strictly stronger than the last:
| Value | Means |
|---|---|
undefined | Required. The container may not omit this slot. |
"optional" | The container may omit it. Nothing is supplied in its place. |
"defaulted" | The container may omit it and this schema substitutes a value. |
Consumers asking "may the slot be absent?" test !== undefined. $ZodOptional asks the stronger question and tests === "defaulted".
This ladder is a restoration: _zod.optionality?: "optional" | "defaulted" existed pre-4.0 and was collapsed to two values as collateral of #4405, which split the single axis into optin/optout. The lost distinction was partially recovered at runtime by the payload.fallback flag (#5939 / #5941) before being restored to the schema here.
| Schema | optin (static) | optin (runtime) | Notes |
|---|---|---|---|
$ZodOptional | "optional" | "defaulted" | top rung of inner, else "optional" | Propagates absence rather than substituting, so a defaulted inner keeps its rung |
$ZodExactOptional | "optional" | "optional" | Same as optional, no value-side undefined widening |
$ZodNonOptional | "optional" | undefined | inherits inner | Only narrows the type of the value |
$ZodDefault | "defaulted" | "defaulted" | Hardcoded — it substitutes |
$ZodPrefault | "defaulted" | "defaulted" | Hardcoded — it substitutes |
$ZodCatch | T["_zod"]["optin"] (defers to inner) | top rung of inner, else "optional" | Static/runtime divergence — see below |
$ZodTransform | inherited (undefined by default) | "optional" | Static/runtime divergence — branch only, prototype |
$ZodPipe | def.in._zod.optin (lazy defer to in side) | same | The leading position of the pipe drives optin |
$ZodPreprocess | B["_zod"]["optin"] (defers to inner) | inherits via pipe (in = transform → "optional") | After the prototype, no constructor body |
$ZodNullable | T["_zod"]["optin"] | same | Transparent |
$ZodReadonly | T["_zod"]["optin"] | same | Transparent |
$ZodUnion | highest rung any option declares | same | defaulted if any option is, else optional if any is |
| Everything else (string, number, etc.) | undefined | undefined | Required by default |
Three schemas declare static optin differently from runtime optin:
$ZodCatch: static defers to inner, runtime is "optional". Why: input type should still show the key as required (catch is a recovery mechanism, not a presence statement), but the runtime should accept absent keys (catch's substitution covers them).
$ZodTransform: static is undefined (inherited), runtime is "optional". Why: same reasoning as catch — transform's static input type stays required, but at runtime the fn runs with whatever input shows up, including undefined.
$ZodPreprocess: inherits via pipe, so both static and runtime trace through def.in = $ZodTransform. Static type ends up B["_zod"]["optin"] because of the interface declaration on $ZodPreprocessInternals (overrides the pipe-inherited type). Runtime ends up "optional" because pipe's runtime defers to def.in.optin = transform.optin = "optional".
The user-visible consequence: z.input<typeof z.object({ a: z.preprocess(fn, T) })> shows a as required, but parse({}) succeeds. Same trick catch uses.
$ZodObject.handlePropertyResult (and the JIT codegen mirroring it):
const isPresent = key in input;
const isOptionalOut = optout === "optional"; // optin and optout both arrive raw
if (!isPresent && isOptionalOut && optin === "optional") {
return; // absent slot, middle rung: contribute nothing at all — no issue, no key
}
if (result.issues.length) {
if (optin !== undefined && isOptionalOut && !isPresent) {
return; // swallow the issue — schema can't possibly succeed on absent input but is allowed to fail
}
final.issues.push(...prefixed);
}
if (!isPresent && optin === undefined) {
if (!result.issues.length) {
final.issues.push({ code: "invalid_type", expected: "nonoptional", input: undefined, path: [key] });
}
return; // never assign on absent + required
}
if (result.value === undefined) {
if (isPresent) (final.value as any)[key] = undefined; // preserve explicit undefined
} else {
(final.value as any)[key] = result.value;
}
The leading gate is what keeps an absent key absent. The ladder says the middle rung permits absence without supplying anything in its place, so whatever the property schema made of undefined is invented rather than substituted, and assigning it would contradict the schema's own declaration. optout is the other half of the gate: a schema that isn't optional-out has to keep the key, which is why z.string().catch("c") — optional-in only — still fills an absent key with "c". Only the top rung reaches the assignment with a value.
The wrapper alone cannot make this call. $ZodOptional never hits the gate with a value because it short-circuits on undefined unless the inner is "defaulted"; $ZodExactOptional deliberately does not short-circuit, because delegating to the inner is the only thing that makes it reject an explicitly present undefined. Since el._zod.run({ value: input[key], issues: [] }, ctx) carries no presence information, $ZodObject is the only place that knows the difference, and the gate belongs there.
$ZodTuple does the analogous thing for trailing tuple slots, where "omit the key" becomes "truncate the tail".
There are three parse paths, and the gate has to appear in all three or compile mode diverges. The interpreted path is handlePropertyResult above; $ZodObjectJIT emits if (<key>_present) instead of if (value !== undefined || <key>_present) for a middle-rung key; and z.compile() assembles its own output object, so compileObject applies dropsWhenAbsent — optin === "optional" && optout === "optional" — to pick the same condition, with the tuple compiler truncating instead of running an absent middle-rung item.
$ZodOptional (the standalone wrapper) reads the inner's optin to decide whether to short-circuit on undefined input:
inst._zod.parse = (payload, ctx) => {
if (def.innerType._zod.optin === "optional") {
const input = payload.value;
const result = def.innerType._zod.run(payload, ctx);
return handleOptionalResult(result, input);
}
if (payload.value === undefined) return payload; // short-circuit: inner doesn't claim to handle undefined, return undefined as-is
return def.innerType._zod.run(payload, ctx);
};
So optional invokes its inner whenever inner says "I handle absence." It only short-circuits when inner is silent on the question.
The JSON Schema emitter consumes optin too, in two places: objectProcessor for required and tupleProcessor for minItems. io: "input" describes the declared input type, so the three schemas above that diverge have to be resolved past to whatever actually carries the optionality. Both go through one helper in json-schema-processors.ts:
function inputOptin(schema: $ZodType): "optional" | undefined {
const def = schema._zod.def;
if (def.type === "pipe" && def.in._zod.traits.has("$ZodTransform")) return inputOptin(def.out);
if (def.type === "catch") return inputOptin(def.innerType);
return schema._zod.optin;
}
Reading _zod.optin directly builds a required list — or a minItems — that matches runtime parse behavior instead of the declared type, dropping a preprocessed or caught slot even though z.input<> shows it as required. #5003 settled the policy — the input JSON Schema describes what you should pass, not everything you can pass — and #6133 restored it after #5939 and #5941 set the runtime flags.
The tuple side went the same way in #6418. Its minItems tail scan used to read the runtime flag, so a trailing preprocessed slot shortened minItems and the emitted schema matched neither the declared type nor the parser:
z.toJSONSchema(z.tuple([z.string(), z.preprocess((v) => v, z.string())]), { io: "input" });
// before: minItems 1 — but z.input<> is [string, string], and .safeParse(["a"]) rejects
// now: minItems 2, matching the object equivalent's required: ["a", "b"]
Output mode still reads optout directly in both processors — the divergence is input-side only.
The same split applies to emitted values, not just requiredness: isTransforming now recurses through catch (#6409), so a catch no longer hides an inner transform from its ancestors and the output-typed default is stripped under io: "input". A catch over a non-transforming inner keeps its default. Both halves answer the same question, and they have to answer it the same way.
optout"Can my output be
undefinedeven when input was present, and should the parent treat that as 'absent' for length-shortening / key-omission?"
A separate axis from optin. Set by:
| Schema | optout |
|---|---|
$ZodOptional | "optional" |
$ZodExactOptional | "optional" |
| Everything else | inherited or undefined |
$ZodObject uses it (combined with optin and isPresent) to decide whether to assign or skip. $ZodTuple uses it to decide whether to trim a trailing slot whose value came back as undefined.
The relevant observation for users: a schema can be input-required, output-optional (z.string().nullable() with some shapes), or input-optional, output-required (z.string().default("d") — accepts absence, never produces undefined). The optin × optout matrix has all four combinations and they all matter for object/tuple parsing.
$ZodOptional decides"Absent input arrived. Do I trust the inner, or yield
undefined?"
$ZodOptional reads the ladder and nothing else:
inst._zod.parse = (payload, ctx) => {
if (payload.value === undefined) {
// Only the top rung substitutes a value for absence; everything else leaves it intact, which is what .optional() means.
if (def.innerType._zod.optin !== "defaulted") return payload;
const result = def.innerType._zod.run(payload, ctx);
if (result instanceof Promise) return result.then(handleOptionalResult);
return handleOptionalResult(result);
}
return def.innerType._zod.run(payload, ctx);
};
Two consequences worth naming:
preprocess fn fired once per absent parse; now it fires zero times. Same result, fewer side effects.handleOptionalResult only has to swallow issues. A substituting schema that still fails (a prefault value its inner rejects) has no usable answer, so it yields undefined.There used to be one. payload.fallback was set by $ZodCatch on substitution and by $ZodTransform on every invocation, propagated by hand through handlePipeResult, and read by handleOptionalResult. It existed to undo a wrong conclusion: $ZodTransform and $ZodCatch declared optin = "optional" so object parsers would run them on absent keys, which made $ZodOptional believe they had answered the absence.
The ladder removes the wrong conclusion instead of undoing it, so the flag is gone. Two things it got wrong along the way, for the record:
$ZodPipe had to copy it explicitly (#5941's first commit was that fix), and $ZodUnion never copied it into its options at all..default() sat between the transform and the optional: the gate tested the optional's input while the transform had received the substituted value. That was #6321.respect vs clobber?When optional receives undefined, the question is: does it return whatever the inner produces (respect), or yield undefined (clobber)?
The rule is the ladder:
inner optin === "defaulted" → respect (run it, use its value)
otherwise → clobber (yield undefined, don't run it)
So:
| Inner schema | optin | result |
|---|---|---|
default fired, prefault filled | "defaulted" | respect — return inner value |
recovery substitution (catch over a non-substituting inner) | "optional" | clobber — return undefined |
| transform's output (preprocess, standalone transform) | "optional" | clobber — return undefined |
| plain required schema | undefined | clobber — return undefined |
| substituting schema that then failed | "defaulted" | clobber (issues swallowed) — return undefined |
They are the only schemas that answer the absence. default short-circuits on undefined input before running inner and returns def.defaultValue; prefault substitutes its value into the input and then runs inner. Both produce a deliberate value rather than an incidental one, which is what the top rung records.
Catch fires only when its inner produces issues. On undefined input, inner failed because it doesn't accept undefined, not because the user wanted the substitution value. So catch doesn't claim the top rung on its own — it carries through whatever its inner declares, which means z.string().default("D").catch("C") is "defaulted" and is respected.
For preprocess, the user's fn runs on undefined only because the outer schema invoked it. Their intent was "transform whatever shows up", but they also wrote .optional() to say "absent input → absent output". The transform never claims the top rung, so .optional() wins — and under the ladder it wins without running the fn.
Note the composition: a transform downstream of a default inherits "defaulted" through the pipe's def.in, so z.string().default("").transform(fn) is respected while z.preprocess(fn, T) is not. That is #6321, fixed structurally rather than by inspecting the transform's input.
Concrete shapes and what they evaluate to.
// === Catch ===
z.string().catch("c").parse(undefined)
// → "c" (catch fires on string(undefined) failure; nothing outer to override it)
z.string().catch("c").parse(123)
// → "c" (catch fires on string(123) failure)
z.string().catch("c").optional().parse(undefined)
// → undefined (catch.optin = "optional", not "defaulted", so optional yields undefined without running it)
z.string().catch("c").optional().parse("hi")
// → "hi" (input defined, so optional just runs the inner)
z.string().catch("c").transform((s) => s + "!").optional().parse(undefined)
// → undefined (pipe.optin = catch.optin = "optional"; optional yields undefined)
z.object({ a: z.string().catch("c") }).parse({})
// → { a: "c" } (catch.optin = "optional" runtime; obj invokes catch with undefined; catch fires)
z.object({ a: z.string().catch("c") }).parse({ a: undefined })
// → { a: "c" } (key present with undefined; catch fires on string(undefined))
z.object({ a: z.string().catch("c").optional() }).parse({})
// → {} (key absent; optional sees optin = "optional", not "defaulted", so yields undefined; obj omits)
// === Default ===
z.string().default("d").parse(undefined)
// → "d" (default short-circuits on undef input, returns d directly)
z.string().default("d").optional().parse(undefined)
// → "d" (default.optin = "defaulted"; optional runs it and respects the result)
z.object({ a: z.string().default("d") }).parse({})
// → { a: "d" } (obj asks optin !== undefined; invokes default; short-circuits to d)
// === Prefault ===
z.string().prefault("p").parse(undefined)
// → "p" (prefault substitutes, runs inner string("p") which succeeds)
z.string().prefault("p").optional().parse(undefined)
// → "p" (prefault.optin = "defaulted" — it answers the absence, so optional respects it)
// === Preprocess ===
z.preprocess((v) => v ?? "X", z.string()).parse(undefined)
// → "X" (preprocess fn produces "X", inner string accepts)
z.preprocess((v) => v ?? "X", z.string()).optional().parse(undefined)
// → undefined (pipe.optin = transform.optin = "optional"; optional yields undefined, fn never runs)
z.object({ a: z.preprocess((v) => v ?? "X", z.string()) }).parse({})
// → { a: "X" } (preprocess.optin = "optional" via transform; obj invokes; fn runs)
z.object({ a: z.preprocess((v) => v ?? "X", z.string()).optional() }).parse({})
// → {} (optional sees "optional", not "defaulted"; yields undefined without running the fn; obj omits)
z.object({ a: z.preprocess((v) => v, z.string().optional()) }).parse({})
// → {} (inner-optional preprocess; #5917/#5929 path)
// === Default feeding a transform (the #6321 shape) ===
z.string().default("").transform((v) => (v ? v.split(",") : [])).optional().parse(undefined)
// → [] (pipe.optin = def.in.optin = default.optin = "defaulted"; optional runs it and respects)
// 4.4.3-4.4.x: was undefined, because transform flagged every invocation as a fallback
z.object({ a: z.string().default("").transform((v) => v.length) }).partial().parse({})
// → { a: 0 } (.partial() wraps in optional; the top rung carries through the pipe)
// === Transform ===
z.string().transform((s) => s + "!").parse("hi")
// → "hi!"
z.string().transform((s) => s + "!").parse(undefined)
// → THROW (string rejects undef before transform runs)
z.transform((v) => v ?? "X").parse(undefined)
// → "X" (transform fn runs on undef, returns "X")
z.transform((v) => v ?? "X").optional().parse(undefined)
// → undefined (transform.optin = "optional"; optional yields undefined, fn never runs)
z.object({ a: z.transform((v) => v ?? "X") }).parse({})
// → { a: "X" } (transform.optin = "optional" runtime; obj invokes transform with undef)
z.object({ a: z.string().transform((s) => s + "!") }).parse({})
// → THROW (pipe.optin = string.optin = undefined — transform on OUT side doesn't drive optin)
z.object({ a: z.unknown().transform((v) => String(v ?? "X")).pipe(z.string()) }).parse({})
// → THROW (outer pipe.optin = inner pipe.optin = unknown.optin = undefined — transform is
// on the OUT side of the inner pipe, which is on the IN side of the outer pipe; the leading
// `z.unknown()` drives optin and it's not "optional")
//
// Compare to z.preprocess(fn, T):
// z.preprocess(fn, T) === pipe(transform(fn), T) — transform IS def.in of the only pipe
// z.unknown().transform(fn).pipe(T) === pipe(pipe(unknown, transform), T) — there's an
// inner pipe with
// z.unknown() on
// the in side
//
// Preprocess accepts absent because its leading position is the transform itself.
// The unknown.transform.pipe(T) shape doesn't, because its leading position is z.unknown().
// If you want preprocess-like absence-handling, use z.preprocess; if you specifically want
// strict input typing on the leading slot, the unknown.transform.pipe shape gives that.
// === Coerce / unknown / any (intentionally strict on absent) ===
z.object({ a: z.coerce.string() }).parse({})
// → THROW (coerce.string.optin = undefined; object rejects absent key)
z.object({ a: z.unknown() }).parse({})
// → THROW (unknown.optin = undefined; soundness fix from 4.4)
z.object({ a: z.any() }).parse({})
// → THROW (same)
// === exactOptional (delegates instead of short-circuiting) ===
z.object({ a: z.coerce.string().exactOptional() }).parse({})
// → {} (absent + middle rung: the object drops what coerce made of undefined)
z.object({ a: z.coerce.string().exactOptional() }).parse({ a: undefined })
// → { a: "undefined" } (present: the inner runs and its answer stands)
z.object({ a: z.string().exactOptional() }).parse({ a: undefined })
// → THROW (present: string rejects undefined, and the object surfaces it)
z.object({ a: z.string().default("x").exactOptional() }).parse({})
// → { a: "x" } (top rung substitutes, so the gate doesn't fire)
z.tuple([z.string(), z.coerce.string().exactOptional()]).parse(["x"])
// → ["x"] (the tuple analog: truncate rather than materialize)
// === Static type vs runtime divergence ===
z.input<typeof z.object({ a: z.string().catch("c") })>
// → { a: string } — `a` is required at the type level
z.object({ a: z.string().catch("c") }).parse({})
// → { a: "c" } — but accepts {} at runtime
z.input<typeof z.object({ a: z.preprocess(fn, T) })>
// → { a: <transform's input type> } — `a` is required at the type level
z.object({ a: z.preprocess(fn, T) }).parse({})
// → { a: fn(undefined) } — accepts {} at runtime
$ZodTuple mirrors $ZodObject's logic for trailing positions. The same optin/optout flags drive whether a missing trailing slot is legal and whether to pad with explicit undefined vs trim.
The structurally-identical helper to handlePropertyResult for tuples is handleTupleResult. Same gate logic, same flag reads.
If you hit an absent-key rejection on a schema kind we intentionally keep strict (coerce, unknown, raw transform-on-the-out-side, etc.), the answer is to declare the absence explicitly:
// Want absence to be allowed on coerce? Wrap in optional:
z.object({ a: z.coerce.string().optional() }).parse({})
// → {}
// Want absence to map to a default?
z.object({ a: z.coerce.string().default("x") }).parse({})
// → { a: "x" }
// Want preprocess to fire on absent inner-required schemas? Use inner-optional:
z.object({ a: z.preprocess(fn, z.string().optional()) }).parse({})
// → {} (or { a: fn(undefined) } if fn returns a defined value — depends on optional)
| PR | What |
|---|---|
| #5661 | Made $ZodObject / $ZodTuple strict about absent slots — consult optin. Source of the 4.4 regressions. |
| #5917 / #5929 | Made preprocess defer optionality to inner schema (so preprocess(fn, X.optional()) worked again). Pure metadata-override subtype design. |
| #5937 / #5939 | Restored $ZodCatch.optin = "optional" runtime + introduced the caught flag so an outer $ZodOptional could clobber catch's recovery. |
| #4405 | Split _zod.optionality into optin / optout. Collapsed the old "optional" | "defaulted" ladder to two values as collateral — the distinction restored below. Pre-4.0, so nothing depended on it. |
| #5941 | Renamed caught → fallback; propagated through $ZodPipe boundaries; also makes $ZodPreprocess.optin = "optional" and $ZodTransform set fallback on every invocation. Restores the bare-preprocess regression. |
| #6321 / #6419 | "Set on every transform invocation" was unsound when a .default() sat between the transform and the outer optional. Restored the third rung (optin = "defaulted") and retired payload.fallback entirely. |
The static/runtime divergence pattern is not an accident — it captures a deliberate philosophy:
Static types stay strict. z.input<typeof schemaWithCatch> shows the field as required. z.input<typeof schemaWithPreprocess> shows it as required. Users writing TypeScript see a contract that says "you must provide this key."
Runtime is flexible. Catch handles failures including the failure of the inner schema to accept undefined. Preprocess and transform run their fns on whatever input shows up, including undefined. The runtime is more permissive than the type.
This is technically unsound — the runtime accepts inputs the type rejects — but it matches what users expect from these primitives. .catch(default) reads as "give me this when something goes wrong, including the absence of input." z.preprocess(fn, T) reads as "run this fn on whatever I get, including missing." Treating the static type as a stricter contract while the runtime is more accommodating is the ergonomic call.
The schemas where the input stays strict at runtime — coerce, unknown, any, plain string/number/etc. — don't have a user-written escape hatch. There's no reason for them to claim to handle absence; they should reject and let the user opt in explicitly. So the rule is: schemas with a user-written escape hatch (catch's recovery, transform's fn) accept undefined at runtime; schemas without one don't.
The optin ladder is what makes this safe to combine with optional: an escape hatch earns "optional" (absence permitted) but not "defaulted" (absence answered), so when an outer wrapper also has an opinion about absent input, the user's explicit .optional() wins. Default and prefault claim the top rung because their values are the deliberate output, not an escape-hatch output.
A schema's optin is a three-rung ladder: undefined (required), "optional" (absence permitted), "defaulted" (absence permitted and substituted for). Object and tuple parsers ask the weak question — !== undefined — before running a slot. Default and prefault claim the top rung; optional, catch, transform and preprocess claim only "optional"; transparent wrappers and the input side of a pipe carry through whatever they wrap, which is how z.string().default("D").transform(fn) stays "defaulted".
When optional receives undefined it asks the strong question — === "defaulted" — and runs the inner only if something down there actually answers the absence. Otherwise it yields undefined without running anything. There is no payload flag: the answer is a property of the schema's shape, so it needs no propagation and cannot be dropped at a combinator boundary.
For everything else — coerce, string, unknown, any, transform.pipe shapes where transform is on the OUT side — optin stays undefined. Object and tuple parsers reject absent input. Users opt in explicitly via .optional() or .default(...) when they want absence accepted.