Repository navigation
fix(v4): restore catch handling for absent object keys (#5937) - #5939
Conversation
|
TL;DR — Restores v4.3.x behavior where Key changes
Summary | 4 files | 1 commit | base:
|
`z.X.catch(...)` previously deferred its `optin` to the inner schema, so
when v4.4.0 tightened object parsing to reject absent keys whose schema
isn't `optin === "optional"`, fields like `z.preprocess(...).catch([])`
started failing on `{}` even though the catch handler should fire.
This restores the v4.3.x behavior by marking `$ZodCatch` as
`optin === "optional"` unconditionally. Catch always handles a missing
input gracefully (its handler runs with `undefined` and produces the
catch value), so it's correct to advertise that to `$ZodObject`.
To keep the existing `optional` semantics where an outer `.optional()`
short-circuits to `undefined` instead of surfacing the catch value, we
add an internal `caught` flag on `ParsePayload`. `$ZodCatch` sets it
unconditionally whenever `catchValue` fires; `handleOptionalResult`
reads it (alongside the existing `issues.length` check) to override the
result with `undefined` when the original input was `undefined`.
Hot path impact: the `caught` check lives in `handleOptionalResult`,
which only runs in `$ZodOptional`'s `optin === "optional"` branch.
Plain `z.string().optional()` parsing is unchanged (~3.3 ns / ~6.6 ns
present/undefined). The one regression is `.catch().optional()` parsing
`undefined`, which goes from ~37 ns to ~200 ns because `$ZodOptional`
no longer skips running the inner catch — that work is now necessary
for correctness.
8733ac8 to
76a802e
Compare
There was a problem hiding this comment.
Important
Runtime fix is correct, but the type-level signature wasn't updated to match. $ZodCatchInternals.optin is still declared as T["_zod"]["optin"] while runtime now forces "optional". As a result, the very pattern this PR fixes — z.object({ arr: z.preprocess(...).catch([]) }).parse({}) — succeeds at runtime but z.input<typeof schema> still requires arr. Same for the z.string().catch("c") case in the PR description's first table.
Reproduced against the PR branch:
const s = z.object({ arr: z.preprocess((v) => v, z.string().array()).catch([]) });
type I = z.input<typeof s>;
const ok: I = {}; // TS2741: Property 'arr' is missingMirror the established $ZodDefault / $ZodPrefault pattern (lines 3620-3627, 3687-3694): narrow optin to "optional" and widen input to core.input<T> | undefined so $InferObjectInput's OptionalInSchema check picks up the key as optional.
Worth adding a type-level assertion (expectTypeOf<z.input<typeof s>>().toEqualTypeOf<{ arr?: ... }>()) alongside the runtime test in catch.test.ts so this gap doesn't reopen.
Implementation otherwise looks good — caught flag on ParsePayload is a clean way to preserve the .catch().optional() override, the input capture in $ZodOptional is necessary and well-commented, and the snapshot/JSON-schema test updates match the restored v4.3.x semantics.
Note: 1 inline comment(s) dropped because they did not anchor to lines inside the PR diff:
packages/zod/src/v4/core/schemas.ts:3884-3887(RIGHT) — line 3887 (RIGHT) is not inside a diff hunk
Claude Opus | 𝕏
There was a problem hiding this comment.
Important
Runtime fix is correct, but the type-level signature wasn't updated to match. $ZodCatchInternals.optin is still declared as T["_zod"]["optin"] (schemas.ts:3887, just above the diff hunk) while runtime now forces "optional". As a result, the very pattern this PR fixes — z.object({ arr: z.preprocess(...).catch([]) }).parse({}) — succeeds at runtime but z.input<typeof schema> still requires arr. Same for the z.string().catch("c") case in the PR description's first table.
Reproduced against the PR branch:
const s = z.object({ arr: z.preprocess((v) => v, z.string().array()).catch([]) });
type I = z.input<typeof s>;
const ok: I = {}; // TS2741: Property 'arr' is missingMirror the established $ZodDefault / $ZodPrefault pattern (schemas.ts:3620-3627 and :3687-3694): narrow optin to "optional" and widen input to core.input<T> | undefined so $InferObjectInput's OptionalInSchema check picks up the key as optional:
export interface $ZodCatchInternals<T extends SomeType = $ZodType>
extends $ZodTypeInternals<core.output<T>, core.input<T> | undefined> {
def: $ZodCatchDef<T>;
optin: "optional";
optout: T["_zod"]["optout"];
isst: never;
values: T["_zod"]["values"];
}Worth adding a type-level assertion (expectTypeOf<z.input<typeof s>>().toEqualTypeOf<{ arr?: ... }>()) alongside the runtime test in catch.test.ts so this gap doesn't reopen.
Implementation otherwise looks good — caught flag on ParsePayload is a clean way to preserve the .catch().optional() override, the input capture in $ZodOptional is necessary, and the snapshot/JSON-schema test updates match the restored v4.3.x semantics.
Claude Opus | 𝕏
| export const $ZodCatch: core.$constructor<$ZodCatch> = /*@__PURE__*/ core.$constructor("$ZodCatch", (inst, def) => { | ||
| $ZodType.init(inst, def); | ||
| util.defineLazy(inst._zod, "optin", () => def.innerType._zod.optin); | ||
| inst._zod.optin = "optional"; |
There was a problem hiding this comment.
Runtime now sets optin: "optional", but the $ZodCatchInternals interface a few lines above (line 3887) still declares optin: T["_zod"]["optin"]. That breaks $InferObjectInput's OptionalInSchema check, so z.object({ a: z.string().catch("c") })'s input type still requires a even though parse({}) now succeeds. Update the interface to optin: "optional" and widen input to core.input<T> | undefined, matching the $ZodDefault pattern at lines 3620-3627.
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Marked
$ZodCatchasoptin === "optional"unconditionally so absent object keys flow through to the catch handler, restoring v4.3.x semantics - Added an internal
caughtflag onParsePayloadthat$ZodCatchsets when its handler fires - Taught
handleOptionalResultto honor the new flag alongsideissues.length, and capturedpayload.valuebefore delegating in$ZodOptionalso the "original input wasundefined" check survives catch rewriting the payload - Updated
catch.test.ts,partial.test.ts, andto-json-schema.test.tsassertions to match the restored behavior (catch/default/prefault fields no longer required;.catch().optional()still short-circuits toundefinedon absent input)
The caught flag is correctly scoped: $ZodObject and handlePipeResult always construct fresh payloads ({ value, issues: [] }) for child parses, so the flag cannot leak across sibling fields or pipe stages. Ran the three updated test files locally, 224/224 pass.
Claude Opus | 𝕏
|
Very much appreciate the quick action on getting a fix done @colinhacks :) On a side note, 4.4.x also breaks several other packages that depends on it, e.g. Nextra, but it seems like restoration of the behavior seen in 4.3.x in the next release will fix these issues. |
…n absent keys (#5941) * fix(v4): propagate fallback flag through pipe boundaries `$ZodCatch` sets a payload flag when its `catchValue` substitutes so an outer `$ZodOptional` can clobber the recovery value with `undefined` (per #5939). But `handlePipeResult` was building a fresh payload for the right side of the pipe without copying the flag, so any chain like `catch().transform()...optional()` lost it — `optional` couldn't tell the inner had recovered, and surfaced the catch value instead of clobbering. Propagate the flag through pipe handoffs, alongside `value`/`issues`. Also rename `caught` to `fallback`: a slightly broader name that describes the consumer contract ("override me if you have a better value when input was undefined") rather than the producer ("catch fired me"). Internal-only; no public API surface. * fix(v4): restore preprocess handling for absent object keys `z.object({ a: z.preprocess(fn, T) }).parse({})` worked in 4.3 (the fn ran with `undefined`, produced a value, the inner schema validated it) but started failing on absent keys after #5661 tightened the object parser. Users commonly use preprocess to inject pre-parse defaults for fields that may be missing — that pattern broke silently in 4.4. Restore by marking $ZodPreprocess as `optin === "optional"`, telling `$ZodObject` that absent keys are legal here. The fn then runs with `undefined` exactly as it did in 4.3. To preserve the long-stable behavior of `preprocess(fn, T).optional() .parse(undefined)` returning `undefined` (true in both 4.3 and 4.4 for multi-year compatibility), have `$ZodTransform` set the `fallback` payload flag on every invocation. `$ZodOptional` already clobbers a result with `fallback === true` when its input was `undefined`, so the outer optional keeps short-circuiting to `undefined` even though the transform now runs underneath. z.object({ a: z.preprocess(v => v ?? "X", z.string()) }).parse({}) // 4.3: { a: "X" } // 4.4: FAIL (regression) // now: { a: "X" } z.preprocess(v => v ?? "X", z.string()).optional().parse(undefined) // 4.3 + 4.4: undefined // now: undefined (preserved) Drops the `optin` defer-to-inner from #5929, but the same outcome holds: when inner is `.optional()`, preprocess still accepts absent keys (`optin === "optional"` either way). * fix(v4): generalize optin=optional from preprocess to transform Promotes the "user-written input handler accepts absence" signal from $ZodPreprocess to $ZodTransform. Any schema with a transform fn at its input boundary (preprocess, standalone z.transform) now declares optin="optional" at runtime. Effects: - preprocess inherits optin="optional" via pipe.optin = transform.optin (same outcome as the previous commit's explicit override; preprocess loses both its optin and optout overrides since pipe already does the optout defer) - standalone z.transform(fn) now accepts absent object keys - z.string().transform(fn): unchanged (pipe.optin = string.optin = undefined; transform on the OUT side doesn't drive optin) - z.unknown().transform(fn).pipe(A): unchanged (pipe.optin = unknown. optin = undefined) The static type stays unchanged — transform's interface doesn't declare optin, so this only sets the runtime value, mirroring the catch pattern. Captures the "flexible inputs, strict outputs" design principle: schemas with a user-written escape hatch (catch's recovery, transform's fn) accept undefined at runtime even when the static type declares the input as required. After this, $ZodPreprocess is a near-empty marker subtype — the constructor body is just $ZodPipe.init(inst, def), kept for type narrowing and traits identity. * docs(wiki): add internal reference for v4 optionality semantics Captures the current state of optin/optout/fallback, who sets each, who reads each, the static/runtime divergence pattern, and walked- through cases for the gnarly interactions (catch+optional, default vs catch vs preprocess vs transform under optional, etc.). Also documents the "flexible inputs, strict outputs" design principle that motivates the runtime/static optin divergence on $ZodCatch and $ZodTransform: schemas with a user-written escape hatch accept undefined at runtime while keeping their declared input type strict. Internal-only doc; not published. * docs(wiki): explain why unknown.transform.pipe stays strict Adds the explicit contrast between z.preprocess(fn, T) (= pipe(transform, T), accepts absent) and z.unknown().transform(fn).pipe(T) (= pipe(pipe( unknown, transform), T), rejects absent). The two look structurally similar but only the leading position drives optin, and z.unknown() isn't input-optional. Also drops the stale "prototype only" caveat from the standalone z.transform(fn) walkthrough — the runtime optin=optional move from preprocess to transform is now a real part of this branch, not a prototype.
Bumps `next` and `eslint-config-next` to 16.2.6 in apps/app, apps/docs, apps/web, and `@next/mdx` to 16.2.6 in apps/app. Also nudges packages/ui's devDep `next` to ^16.2.6 so the npm hoist tree settles on a single root-level `next@16.2.6` (mixed-version hoist breaks NextRequest typing in next-auth). apps/web-admin stays on Next 15 — its 15 → 16 major upgrade is tracked separately. Why patch-package + nextra-theme-docs patch: Next 16.2.6's vendored React (19.3.0-canary) implements __COMPILER_RUNTIME.c, which actually executes the React Compiler memo cache in async server components. This exposes a latent bug in nextra-theme-docs@4.6.1 where `Layout` strips `children` from props before LayoutPropsSchema.safeParse(themeConfig), while the schema declares `children: reactNode` as non-optional. Combined with Zod 4.4.x's stricter nonoptional check (see colinhacks/zod#5939), every /[lang]/[[...mdxPath]] page fails to prerender on next build. Upstream fix: shuding/nextra#4990 (merged 2026-05-07, not yet released). Issue: shuding/nextra#4989. We mirror the fix at the dist level: mark `children` as optional in LayoutPropsSchema. `Layout` consumes `children` from the outer-scope destructure, so removing the schema requirement is runtime-equivalent to the upstream change. Once nextra-theme-docs ships a release containing #4990, this patch can be dropped. Closes #475 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`$ZodCatch` sets `optin = "optional"` at runtime so the parser lets the catch handler observe an absent key (colinhacks#5939), but its declared input type keeps the key required. `io: "input"` describes the declared type, so resolve past catch the same way this now resolves past a transform-led pipe. colinhacks#5939 flipped this without comment and dropped `"f"` from the `input type` snapshot, reverting the position established in colinhacks#4941 and colinhacks#5003. Restore both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`$ZodCatch` sets `optin = "optional"` at runtime so the parser lets the catch handler observe an absent key (colinhacks#5939), but its declared input type keeps the key required. `io: "input"` describes the declared type, so resolve past catch the same way this now resolves past a transform-led pipe. colinhacks#5939 flipped this without comment and dropped `"f"` from the `input type` snapshot, reverting the position established in colinhacks#4941 and colinhacks#5003. Restore both. Also updates the object snapshot added by colinhacks#6409: a catch over a transforming schema declares a `string` input, so the key is required there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… mode (#6133) * fix(json-schema): keep preprocess object properties required in input mode `z.preprocess(fn, inner)` builds a pipe whose `in` is a transform. A bare transform reports `optin: "optional"` (needed so the preprocessor still runs on absent keys at parse time), and ZodPipe inherits `optin` from its `in`, so the object processor marked preprocessed properties as not-required in `toJSONSchema(..., { io: "input" })` — a regression since v4.4.3 (#5941). This contradicts runtime parsing: `z.object({ a: z.preprocess(v => v, z.string()) }).parse({})` rejects the absent `a`. The input shape of a preprocess pipe is its inner `out` schema, so optionality should defer to that inner schema (the v4.4.2 behavior, design of #5929). Resolve `optin` through transform-`in` pipes in the JSON-schema object processor only; runtime optin is untouched. Fixes #5968. * fix(json-schema): keep catch properties required in input mode `$ZodCatch` sets `optin = "optional"` at runtime so the parser lets the catch handler observe an absent key (#5939), but its declared input type keeps the key required. `io: "input"` describes the declared type, so resolve past catch the same way this now resolves past a transform-led pipe. #5939 flipped this without comment and dropped `"f"` from the `input type` snapshot, reverting the position established in #4941 and #5003. Restore both. Also updates the object snapshot added by #6409: a catch over a transforming schema declares a `string` input, so the key is required there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: MerlijnW70 <196433316+MerlijnW70@users.noreply.github.com> Co-authored-by: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Summary
Closes #5937.
In v4.4.0,
fd0f83d1(PR #5661) tightened$ZodObjectto reject absent keys whose schema doesn't haveoptin === "optional".$ZodCatchwas deferring itsoptinto the inner schema, so anyz.X.catch(...)field on anoptin-required schema (e.g.z.preprocess(...).catch([]),z.enum([...]).catch(...)) started failing to parse{}even though the catch handler should fire.This PR restores the v4.3.x behavior by marking
$ZodCatchasoptin === "optional"unconditionally. A catch wrapper always handles missing input gracefully — its handler runs withundefinedand produces the catch value — so it's correct to advertise that to$ZodObject.To preserve the existing semantics where an outer
.optional()overrides the catch fallback withundefined, this introduces an internalcaughtflag onParsePayload:$ZodCatchsetspayload.caught = truewhenevercatchValuefires (unconditionally — noundefinedcheck).handleOptionalResultreads it alongside the existingissues.lengthcheck, so an outeroptionalreturnsundefinedwhen the original input wasundefinedand either the inner produced issues or a catch fired.$ZodOptionalcaptures the input value before invoking the inner schema (a wrapped$ZodCatchrewritespayload.valueto its catchValue, so the post-run value can't be used to detect "input wasundefined").Behavior changes (vs v4.4.x)
These restore v4.3.x semantics for
catchfields on objects:z.object({ arr: z.preprocess(..., z.string().array()).catch([]) }){}{ arr: [] }z.object({ a: z.string().catch("c") }){}{ a: "c" }z.object({ fruit: z.enum([...]).catch("apple") }){}{ fruit: "apple" }.optional()semantics are preserved:z.string().catch("c").optional()undefinedundefined(catch suppressed by outer optional)z.string().catch("c").optional()123"c"(catch fires)z.string().catch("c").optional()"hi""hi"JSON Schema generation no longer marks bare
.catch()fields asrequired(they're now legitimately optional-in).Performance
The
caughtcheck lives insidehandleOptionalResult, which only runs on$ZodOptional'soptin === "optional"branch. The plain optional hot path is untouched.optional plain — presentoptional plain — undefinedoptional<default> — undefinedoptional<catch> — presentoptional<catch> — undefinedobject{a?,b?,c?} — partial/empty/fullobject{catch,catch} — invalidThe one regression is
.catch().optional()parsingundefined. Cause:$ZodCatchis nowoptin === "optional", so$ZodOptionaltakes branch A (run inner) instead of short-circuiting onundefined. Catch genuinely fires and produces a value, thenhandleOptionalResultoverrides withundefined. That work is necessary for correctness — there's no way to know whethercaughtshould be set without running catch.Test plan
pnpm vitest run— 3807/3807 passing{ arr: [] }z.string().catch("c").optional()parity matrix (undefined,"hi",123)catch.test.ts(enum/native enum),partial.test.ts(catch/prefault/default snapshot),to-json-schema.test.ts(input type required array) to reflect restored v4.3.x semantics