Skip to content

Commit c2be4f8

Browse files
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/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2034,10 +2034,12 @@ export const ZodTransform: core.$constructor<ZodTransform> = /*@__PURE__*/ core.
20342034
if (output instanceof Promise) {
20352035
return output.then((output) => {
20362036
payload.value = output;
2037+
payload.fallback = true;
20372038
return payload;
20382039
});
20392040
}
20402041
payload.value = output;
2042+
payload.fallback = true;
20412043
return payload;
20422044
};
20432045
}

packages/zod/src/v4/classic/tests/catch.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,50 @@ test("direction-aware catch", () => {
277277
// But valid values should still work in reverse
278278
expect(z.encode(schema, "world")).toBe("world");
279279
});
280+
281+
test("optional clobbers catch through pipe boundaries", () => {
282+
expect(
283+
z
284+
.string()
285+
.catch("X")
286+
.transform((s) => s + "!")
287+
.optional()
288+
.parse(undefined)
289+
).toBeUndefined();
290+
expect(z.string().catch("X").pipe(z.string()).optional().parse(undefined)).toBeUndefined();
291+
expect(
292+
z
293+
.string()
294+
.catch("X")
295+
.transform((s) => s + "!")
296+
.transform((s) => s.toLowerCase())
297+
.optional()
298+
.parse(undefined)
299+
).toBeUndefined();
300+
expect(
301+
z
302+
.object({
303+
a: z
304+
.string()
305+
.catch("X")
306+
.transform((s) => s + "!")
307+
.optional(),
308+
})
309+
.parse({})
310+
).toEqual({});
311+
312+
expect(
313+
z
314+
.string()
315+
.catch("X")
316+
.transform((s) => s + "!")
317+
.parse("hi")
318+
).toBe("hi!");
319+
expect(
320+
z
321+
.string()
322+
.catch("X")
323+
.transform((s) => s + "!")
324+
.parse(123)
325+
).toBe("X!");
326+
});

packages/zod/src/v4/classic/tests/preprocess.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,31 @@ test("perform transform with non-fatal issues", () => {
281281
`);
282282
});
283283

284+
test("preprocess accepts absent object keys (4.3 parity)", () => {
285+
const schema = z.object({ a: z.preprocess((v) => v ?? "X", z.string()) });
286+
expect(schema.parse({})).toEqual({ a: "X" });
287+
expect(schema.parse({ a: "hi" })).toEqual({ a: "hi" });
288+
expect(schema.parse({ a: undefined })).toEqual({ a: "X" });
289+
290+
// Outer optional clobbers preprocess output on undefined input
291+
expect(
292+
z
293+
.preprocess((v) => v ?? "X", z.string())
294+
.optional()
295+
.parse(undefined)
296+
).toBeUndefined();
297+
expect(
298+
z
299+
.preprocess((v) => v ?? "X", z.string())
300+
.optional()
301+
.parse("hi")
302+
).toBe("hi");
303+
expect(z.object({ a: z.preprocess((v) => v ?? "X", z.string()).optional() }).parse({})).toEqual({});
304+
305+
// Top-level direct call unchanged
306+
expect(z.preprocess((v) => v ?? "X", z.string()).parse(undefined)).toBe("X");
307+
});
308+
284309
// https://github.com/colinhacks/zod/issues/5917
285310
test("optional propagates through preprocess inside object", () => {
286311
const outer = z.object({ x: z.preprocess((v) => v, z.number()).optional() });

packages/zod/src/v4/core/schemas.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ export interface ParsePayload<T = unknown> {
3636
issues: errors.$ZodRawIssue[];
3737
/** A way to mark a whole payload as aborted. Used in codecs/pipes. */
3838
aborted?: boolean;
39-
/** @internal Set when $ZodCatch substitutes its catchValue. */
40-
caught?: boolean;
39+
/** @internal Marks a value as a fallback that an outer wrapper (e.g.
40+
* $ZodOptional) may override with its own interpretation when input was
41+
* undefined. Set by $ZodCatch when catchValue substitutes and by every
42+
* $ZodTransform invocation. */
43+
fallback?: boolean | undefined;
4144
}
4245

4346
export type CheckFn<T> = (input: ParsePayload<T>) => util.MaybeAsync<void>;
@@ -3419,6 +3422,7 @@ export const $ZodTransform: core.$constructor<$ZodTransform> = /*@__PURE__*/ cor
34193422
"$ZodTransform",
34203423
(inst, def) => {
34213424
$ZodType.init(inst, def);
3425+
inst._zod.optin = "optional";
34223426
inst._zod.parse = (payload, ctx) => {
34233427
if (ctx.direction === "backward") {
34243428
throw new core.$ZodEncodeError(inst.constructor.name);
@@ -3429,6 +3433,7 @@ export const $ZodTransform: core.$constructor<$ZodTransform> = /*@__PURE__*/ cor
34293433
const output = _out instanceof Promise ? _out : Promise.resolve(_out);
34303434
return output.then((output) => {
34313435
payload.value = output;
3436+
payload.fallback = true;
34323437
return payload;
34333438
});
34343439
}
@@ -3438,6 +3443,7 @@ export const $ZodTransform: core.$constructor<$ZodTransform> = /*@__PURE__*/ cor
34383443
}
34393444

34403445
payload.value = _out;
3446+
payload.fallback = true;
34413447
return payload;
34423448
};
34433449
}
@@ -3470,7 +3476,7 @@ export interface $ZodOptional<T extends SomeType = $ZodType> extends $ZodType {
34703476
}
34713477

34723478
function handleOptionalResult(result: ParsePayload, input: unknown) {
3473-
if (input === undefined && (result.issues.length || result.caught)) {
3479+
if (input === undefined && (result.issues.length || result.fallback)) {
34743480
return { issues: [], value: undefined };
34753481
}
34763482
return result;
@@ -3912,7 +3918,7 @@ export const $ZodCatch: core.$constructor<$ZodCatch> = /*@__PURE__*/ core.$const
39123918
input: payload.value,
39133919
});
39143920
payload.issues = [];
3915-
payload.caught = true;
3921+
payload.fallback = true;
39163922
}
39173923

39183924
return payload;
@@ -3930,7 +3936,7 @@ export const $ZodCatch: core.$constructor<$ZodCatch> = /*@__PURE__*/ core.$const
39303936
});
39313937

39323938
payload.issues = [];
3933-
payload.caught = true;
3939+
payload.fallback = true;
39343940
}
39353941

39363942
return payload;
@@ -4035,7 +4041,7 @@ function handlePipeResult(left: ParsePayload, next: $ZodType, ctx: ParseContextI
40354041
left.aborted = true;
40364042
return left;
40374043
}
4038-
return next._zod.run({ value: left.value, issues: left.issues }, ctx);
4044+
return next._zod.run({ value: left.value, issues: left.issues, fallback: left.fallback }, ctx);
40394045
}
40404046

40414047
////////////////////////////////////////////
@@ -4149,8 +4155,6 @@ export const $ZodPreprocess: core.$constructor<$ZodPreprocess> = /*@__PURE__*/ c
41494155
"$ZodPreprocess",
41504156
(inst, def) => {
41514157
$ZodPipe.init(inst, def);
4152-
util.defineLazy(inst._zod, "optin", () => def.out._zod.optin);
4153-
util.defineLazy(inst._zod, "optout", () => def.out._zod.optout);
41544158
}
41554159
);
41564160

0 commit comments

Comments
 (0)