Commit c2be4f8
authored
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.1 parent 1cab693 commit c2be4f8
5 files changed
Lines changed: 486 additions & 8 deletions
File tree
- packages/zod/src/v4
- classic
- tests
- core
- wiki
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2034 | 2034 | | |
2035 | 2035 | | |
2036 | 2036 | | |
| 2037 | + | |
2037 | 2038 | | |
2038 | 2039 | | |
2039 | 2040 | | |
2040 | 2041 | | |
| 2042 | + | |
2041 | 2043 | | |
2042 | 2044 | | |
2043 | 2045 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
277 | 277 | | |
278 | 278 | | |
279 | 279 | | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
281 | 281 | | |
282 | 282 | | |
283 | 283 | | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
284 | 309 | | |
285 | 310 | | |
286 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
39 | | - | |
40 | | - | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
41 | 44 | | |
42 | 45 | | |
43 | 46 | | |
| |||
3419 | 3422 | | |
3420 | 3423 | | |
3421 | 3424 | | |
| 3425 | + | |
3422 | 3426 | | |
3423 | 3427 | | |
3424 | 3428 | | |
| |||
3429 | 3433 | | |
3430 | 3434 | | |
3431 | 3435 | | |
| 3436 | + | |
3432 | 3437 | | |
3433 | 3438 | | |
3434 | 3439 | | |
| |||
3438 | 3443 | | |
3439 | 3444 | | |
3440 | 3445 | | |
| 3446 | + | |
3441 | 3447 | | |
3442 | 3448 | | |
3443 | 3449 | | |
| |||
3470 | 3476 | | |
3471 | 3477 | | |
3472 | 3478 | | |
3473 | | - | |
| 3479 | + | |
3474 | 3480 | | |
3475 | 3481 | | |
3476 | 3482 | | |
| |||
3912 | 3918 | | |
3913 | 3919 | | |
3914 | 3920 | | |
3915 | | - | |
| 3921 | + | |
3916 | 3922 | | |
3917 | 3923 | | |
3918 | 3924 | | |
| |||
3930 | 3936 | | |
3931 | 3937 | | |
3932 | 3938 | | |
3933 | | - | |
| 3939 | + | |
3934 | 3940 | | |
3935 | 3941 | | |
3936 | 3942 | | |
| |||
4035 | 4041 | | |
4036 | 4042 | | |
4037 | 4043 | | |
4038 | | - | |
| 4044 | + | |
4039 | 4045 | | |
4040 | 4046 | | |
4041 | 4047 | | |
| |||
4149 | 4155 | | |
4150 | 4156 | | |
4151 | 4157 | | |
4152 | | - | |
4153 | | - | |
4154 | 4158 | | |
4155 | 4159 | | |
4156 | 4160 | | |
| |||
0 commit comments