Skip to content

fix(v4): restore catch handling for absent object keys (#5937) - #5939

Merged
colinhacks merged 1 commit into
mainfrom
catch-optin-runtime
May 3, 2026
Merged

colinhacks merged 1 commit into
mainfrom
catch-optin-runtime

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Summary

Closes #5937.

In v4.4.0, fd0f83d1 (PR #5661) tightened $ZodObject to reject absent keys whose schema doesn't have optin === "optional". $ZodCatch was deferring its optin to the inner schema, so any z.X.catch(...) field on an optin-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 $ZodCatch as optin === "optional" unconditionally. A catch wrapper always handles missing input gracefully — its handler runs with undefined and 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 with undefined, this introduces an internal caught flag on ParsePayload:

  • $ZodCatch sets payload.caught = true whenever catchValue fires (unconditionally — no undefined check).
  • handleOptionalResult reads it alongside the existing issues.length check, so an outer optional returns undefined when the original input was undefined and either the inner produced issues or a catch fired.
  • $ZodOptional captures the input value before invoking the inner schema (a wrapped $ZodCatch rewrites payload.value to its catchValue, so the post-run value can't be used to detect "input was undefined").

Behavior changes (vs v4.4.x)

These restore v4.3.x semantics for catch fields on objects:

Schema Input v4.4.x This PR
z.object({ arr: z.preprocess(..., z.string().array()).catch([]) }) {} error { arr: [] }
z.object({ a: z.string().catch("c") }) {} error { a: "c" }
z.object({ fruit: z.enum([...]).catch("apple") }) {} error { fruit: "apple" }

.optional() semantics are preserved:

Schema Input Result
z.string().catch("c").optional() undefined undefined (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 as required (they're now legitimately optional-in).

Performance

The caught check lives inside handleOptionalResult, which only runs on $ZodOptional's optin === "optional" branch. The plain optional hot path is untouched.

Bench (p75) Baseline This PR
optional plain — present 3.44 ns 3.32 ns
optional plain — undefined 6.61 ns 6.61 ns
optional<default> — undefined 48.91 ns 48.91 ns
optional<catch> — present 83 ns 83 ns
optional<catch> — undefined 37 ns 208 ns
object{a?,b?,c?} — partial/empty/full flat flat
object{catch,catch} — invalid 375 ns 375 ns

The one regression is .catch().optional() parsing undefined. Cause: $ZodCatch is now optin === "optional", so $ZodOptional takes branch A (run inner) instead of short-circuiting on undefined. Catch genuinely fires and produces a value, then handleOptionalResult overrides with undefined. That work is necessary for correctness — there's no way to know whether caught should be set without running catch.

Test plan

  • pnpm vitest run — 3807/3807 passing
  • Issue Upgrading from 4.3.6 to 4.4.x causes issues with preprocess + catch #5937 reproduction returns { arr: [] }
  • z.string().catch("c").optional() parity matrix (undefined, "hi", 123)
  • Updated assertions in 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

@pullfrog

pullfrog Bot commented May 3, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR — Restores v4.3.x behavior where .catch(...) fields on an object accept absent keys. $ZodCatch is now unconditionally optin === "optional", and a new internal caught flag on ParsePayload keeps outer .optional() wrappers able to override the catch value with undefined.

Key changes

  • Mark $ZodCatch as optin === "optional" — absent object keys once again flow into the catch handler instead of erroring under $ZodObject's tightened check from fd0f83d1.
  • Add caught flag on ParsePayload — $ZodCatch sets it whenever its catchValue fires, giving downstream schemas a way to detect a catch without inspecting values.
  • Teach handleOptionalResult about caught — outer $ZodOptional still returns undefined when the input was undefined and either issues surfaced or a catch fired, preserving z.string().catch("c").optional() semantics.
  • Capture original input in $ZodOptional — a wrapped $ZodCatch rewrites payload.value, so $ZodOptional now snapshots payload.value before running the inner schema to decide whether to short-circuit.
  • Update tests and JSON Schema snapshot — catch.test.ts, partial.test.ts, and to-json-schema.test.ts now reflect the restored v4.3.x behavior (absent keys handled, bare .catch() fields no longer required).

Summary | 4 files | 1 commit | base: main ← catch-optin-runtime


$ZodCatch advertises optin === "optional"

Before: z.object({ arr: z.preprocess(..., z.string().array()).catch([]) }).parse({}) errored because $ZodObject rejected the absent arr key.
After: the catch wrapper signals it handles missing input, so absent keys flow through and the catch value is produced (e.g. { arr: [] }).

Previously $ZodCatch lazily deferred its optin to the inner schema. Any z.X.catch(...) field where the inner schema required optin (e.g. z.preprocess(...), z.enum(...)) started failing on {} after fd0f83d1. Since a catch wrapper always handles undefined gracefully by running its handler, advertising optin === "optional" is correct.

schemas.ts


Outer .optional() still overrides catch on undefined

Before: handleOptionalResult only returned undefined when the inner schema produced issues and input was undefined.
After: it also returns undefined when a catch fired on undefined input, keyed off the new payload.caught flag.

Because $ZodCatch rewrites payload.value to its fallback, $ZodOptional can no longer use the post-run value to detect "input was undefined". The fix snapshots payload.value before running the inner and threads that into handleOptionalResult. An outer .optional() therefore keeps mapping undefined input to undefined output even when the catch handler fires internally.

How does the `caught` flag interact with `$ZodOptional`'s two branches?

$ZodOptional has two execution paths. The plain path (optin !== "optional") short-circuits on undefined and is untouched by this change. The optin === "optional" path runs the inner schema unconditionally, then funnels the result through handleOptionalResult, which now consults both issues.length and result.caught. Because $ZodCatch is now in that second branch, .catch().optional() parsing undefined runs the catch handler (producing a value) and then overrides with undefined — correct, but measurably slower on that specific path.

schemas.ts


Test and JSON Schema updates

Before: z.object({ fruit: z.enum([...]).catch(...) }).safeParse({}) was expected to fail, and z.string().catch(...) appeared in a JSON Schema required array.
After: absent keys resolve to the catch value, and bare .catch() fields are omitted from required as legitimately optional-in.

catch.test.ts flips the enum/native enum assertions, partial.test.ts replaces the nonoptional issue snapshot with a value snapshot showing d: "catch value" alongside default/prefault peers, and to-json-schema.test.ts drops "f" from the input-type required array.

catch.test.ts · partial.test.ts · to-json-schema.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus | 𝕏

`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.
@colinhacks
colinhacks force-pushed the catch-optin-runtime branch from 8733ac8 to 76a802e Compare May 3, 2026 20:11

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 missing

Mirror 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

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 missing

Mirror 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using 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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Marked $ZodCatch as optin === "optional" unconditionally so absent object keys flow through to the catch handler, restoring v4.3.x semantics
  • Added an internal caught flag on ParsePayload that $ZodCatch sets when its handler fires
  • Taught handleOptionalResult to honor the new flag alongside issues.length, and captured payload.value before delegating in $ZodOptional so the "original input was undefined" check survives catch rewriting the payload
  • Updated catch.test.ts, partial.test.ts, and to-json-schema.test.ts assertions to match the restored behavior (catch/default/prefault fields no longer required; .catch().optional() still short-circuits to undefined on 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.

Pullfrog  | View workflow run | Using Claude Opus | 𝕏

@colinhacks
colinhacks merged commit 1cab693 into main May 3, 2026
10 checks passed
@colinhacks
colinhacks deleted the catch-optin-runtime branch May 3, 2026 21:38
@terrymun

terrymun commented May 4, 2026 •

Copy link
Copy Markdown

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.

colinhacks added a commit that referenced this pull request May 4, 2026
…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.
korutx added a commit to microboxlabs/modulariot that referenced this pull request May 15, 2026
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>
colinhacks added a commit to MerlijnW70/zod that referenced this pull request Aug 16, 2026
`$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>
colinhacks added a commit to MerlijnW70/zod that referenced this pull request Aug 16, 2026
`$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>
colinhacks added a commit that referenced this pull request Aug 16, 2026
… 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>

This branch was successfully deployed

1 active deployment
Preview – zod-v4 — 76a802ea Deployed May 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upgrading from 4.3.6 to 4.4.x causes issues with preprocess + catch

2 participants