1 Commits
Author SHA1 Message Date
Colin McDonnellandGitHub 9bfe634a87 fix(v4): generalize optin/fallback to transform; restore preprocess on 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.
2026-05-04 00:01:17 -07:00