mirror of
https://github.com/hanzoai/zod.git
synced 2026-09-24 17:04:49 +00:00
main
1
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |