Skip to content

Commit 02c2baf

Browse files
authored
Make z.preprocess defer optionality to inner schema (#5929)
* Make z.preprocess defer optionality to inner schema (#5917) `z.preprocess(fn, schema)` desugared to `pipe(transform(fn), schema)`, so the resulting pipe's `optin` was inherited from `ZodTransform` (undefined) instead of from the inner schema. When the inner schema was itself optional and the preprocess sat as an object property, the object compiler took the `!isOptionalIn` branch and synthesized a `nonoptional` issue for missing keys — making the position of `.optional()` change presence semantics. Introduce `ZodPreprocess` as a structural subtype of `ZodPipe` (same `def.type === "pipe"`, `instanceof ZodPipe` still true). It pins `def.in` to a permissive `z.unknown()` and re-installs the four lazy metadata props so `optin`, `optout`, `values`, and `propValues` all defer to `def.out` (the inner schema). Backward direction throws, matching today's behavior. The two JSON-schema sites that special-cased the old `pipe(transform, ...)` shape (`pipeProcessor` and `isTransforming`) now also detect preprocess via `_zod.traits`. * Stop deferring values/propValues from inner schema `values` and `propValues` describe the *input* set a schema accepts. Preprocess opens that set to anything `fn` can map to a B-accepted value, so deferring these to B was unsound: - discriminated unions use `propValues` as a fast-path disc map; with B's propValues exposed, an option claims to match only B's literal inputs and silently routes wrong (e.g. preprocess(toUpperCase, z.literal("A")) would fail to match input "a"). Now reverts to throwing at construction, matching pre-PR behavior. - `z.record()` uses `values` to enumerate expected keys; with B's values exposed it short-circuits before the preprocess fn ever runs on input keys. Presence semantics (`optin`/`optout`) describe whether the *outer* container can omit this slot; preprocess is transparent to those, so they continue to defer to B (the original #5917 fix). * Add type-level tests for ZodPreprocess assignability Document the invariant that ZodPreprocess<B> is structurally assignable to ZodPipe<$ZodType, B>, and that the optin/optout override surfaces B's declared type (narrowed for schemas like ZodOptional that hardcode it, open `"optional" | undefined` for schemas like ZodString that inherit it). * Use traits.has consistently in pipeProcessor `pipeProcessor` was mixing detection styles: `traits.has("$ZodPreprocess")` for the new subtype and `def.in._zod.def.type === "transform"` for the legacy pipe(transform, ...) form. Both check "input contributes no validation, use B for input-side JSON schema." Converge on traits. * Treat ZodCodec as transforming for JSON schema example/default stripping Same diagnosis as the preprocess case: `isTransforming` recurses into `def.in` and `def.out` looking for `def.type === "transform"`, but `ZodCodec` embeds an implicit transform fn (its `decode`/`encode`) while `def.in` and `def.out` are validating schemas. The recursion finds no transform and returns false even though the codec absolutely is transforming, causing output-side examples/defaults to leak into the input JSON schema. Detect via `_zod.traits.has("$ZodCodec")` alongside the preprocess check. * Document ZodPreprocess in inheritance hierarchy Surface ZodPreprocess everywhere the codebase enumerates first-party types as a structural sibling of ZodCodec under ZodPipe: - core.mdx: $ZodTypes union comment ("$ZodCodec and $ZodPreprocess extend this") and the inheritance diagram - assignability.test.ts: explicit `satisfies` checks confirming the type-level relationship to $ZodPreprocess and $ZodPipe (with generics specified — the bare-generic form bites variance because inherited reverseTransform is contravariant in B) The $ZodTypes union itself doesn't need a new entry — preprocess is covered transitively via $ZodPipe, same as $ZodCodec — but documenting the relationship in prose and tests makes the hierarchy discoverable. * Simplify ZodPreprocess to a pure metadata-override subtype The bug is purely a static-metadata problem: $ZodPipeInternals.optin was inheriting from A (the leading transform, which has no optin) instead of from B (the inner schema). The runtime parse path for pipe(transform(fn), schema) was already correct. So the simpler design is: keep the runtime structure exactly as the legacy form (def.in is a real ZodTransform), and just override the optin/optout lazies in a thin subtype. What this eliminates from the previous design: - Custom parse function in $ZodPreprocess.init (forward direction with embedded transform fn) - Custom parse function in classic ZodPreprocess.init (addIssue injection — the inner ZodTransform's classic parse override already does this) - The synthetic z.unknown() input slot - The required `transform` field on the def - traits.has("$ZodPreprocess") check in pipeProcessor and isTransforming — def.in is a real ZodTransform, so the existing detection paths fire correctly The traits.has("$ZodCodec") check in isTransforming stays — codec embeds its transform fn directly on the def (different shape) and needs its own detection. * Trim verbose comments to match house style
1 parent 8ec4e73 commit 02c2baf

9 files changed

Lines changed: 156 additions & 4 deletions

File tree

packages/docs/content/packages/core.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export type $ZodTypes =
8484
| $ZodNonOptional
8585
| $ZodReadonly
8686
| $ZodNaN
87-
| $ZodPipe // $ZodCodec extends this
87+
| $ZodPipe // $ZodCodec and $ZodPreprocess extend this
8888
| $ZodSuccess
8989
| $ZodCatch
9090
| $ZodFile;
@@ -156,6 +156,7 @@ export type $ZodTypes =
156156
- $ZodNaN
157157
- $ZodPipe
158158
- $ZodCodec
159+
- $ZodPreprocess
159160
- $ZodReadonly
160161
- $ZodTemplateLiteral
161162
- $ZodCustom

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2361,6 +2361,22 @@ export function invertCodec<A extends core.SomeType, B extends core.SomeType>(co
23612361
}) as any;
23622362
}
23632363

2364+
// ZodPreprocess
2365+
export interface ZodPreprocess<B extends core.SomeType = core.$ZodType>
2366+
extends ZodPipe<core.$ZodTransform, B>,
2367+
core.$ZodPreprocess<B> {
2368+
"~standard": ZodStandardSchemaWithJSON<this>;
2369+
_zod: core.$ZodPreprocessInternals<B>;
2370+
def: core.$ZodPreprocessDef<B>;
2371+
}
2372+
export const ZodPreprocess: core.$constructor<ZodPreprocess> = /*@__PURE__*/ core.$constructor(
2373+
"ZodPreprocess",
2374+
(inst, def) => {
2375+
ZodPipe.init(inst, def);
2376+
core.$ZodPreprocess.init(inst, def);
2377+
}
2378+
);
2379+
23642380
// ZodReadonly
23652381
export interface ZodReadonly<T extends core.SomeType = core.$ZodType>
23662382
extends _ZodType<core.$ZodReadonlyInternals<T>>,
@@ -2645,6 +2661,10 @@ export function json(params?: string | core.$ZodCustomParams): ZodJSONSchema {
26452661
export function preprocess<A, U extends core.SomeType, B = unknown>(
26462662
fn: (arg: B, ctx: core.$RefinementCtx) => A,
26472663
schema: U
2648-
): ZodPipe<ZodTransform<A, B>, U> {
2649-
return pipe(transform(fn as any), schema as any) as any;
2664+
): ZodPreprocess<U> {
2665+
return new ZodPreprocess({
2666+
type: "pipe",
2667+
in: transform(fn as any) as any as core.$ZodTransform,
2668+
out: schema as any as core.$ZodType,
2669+
}) as any;
26502670
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ test("assignability", () => {
143143
z.unknown().pipe(z.number()) satisfies z.core.$ZodPipe;
144144
z.unknown().pipe(z.number()) satisfies z.ZodPipe;
145145

146+
// $ZodPreprocess
147+
z.preprocess((v) => v, z.number()) satisfies z.core.$ZodPreprocess;
148+
z.preprocess((v) => v, z.number()) satisfies z.ZodPreprocess;
149+
z.preprocess((v) => v, z.number()) satisfies z.core.$ZodPipe<z.core.$ZodTransform, z.ZodNumber>;
150+
z.preprocess((v) => v, z.number()) satisfies z.ZodPipe<z.core.$ZodTransform, z.ZodNumber>;
151+
146152
// $ZodSuccess
147153
z.success(z.string()) satisfies z.core.$ZodSuccess;
148154
z.success(z.string()) satisfies z.ZodSuccess;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expectTypeOf, test } from "vitest";
2+
import * as z from "zod/v4";
3+
4+
test("ZodPreprocess<B> assignable to ZodPipe<$ZodTransform, B>", () => {
5+
const pre = z.preprocess((v) => v, z.string().optional());
6+
const _asPipe: z.ZodPipe<z.core.$ZodTransform, z.ZodOptional<z.ZodString>> = pre;
7+
const _asCorePipe: z.core.$ZodPipe<z.core.$ZodTransform, z.ZodOptional<z.ZodString>> = pre;
8+
expectTypeOf(_asPipe).toMatchTypeOf<z.ZodPipe>();
9+
expectTypeOf(_asCorePipe).toMatchTypeOf<z.core.$ZodPipe>();
10+
});
11+
12+
test("ZodPreprocess optin/optout defer to B", () => {
13+
const optionalInside = z.preprocess((v) => v, z.string().optional());
14+
expectTypeOf<(typeof optionalInside)["_zod"]["optin"]>().toEqualTypeOf<"optional">();
15+
expectTypeOf<(typeof optionalInside)["_zod"]["optout"]>().toEqualTypeOf<"optional">();
16+
17+
const required = z.preprocess((v) => v, z.string());
18+
expectTypeOf<(typeof required)["_zod"]["optin"]>().toEqualTypeOf<"optional" | undefined>();
19+
expectTypeOf<(typeof required)["_zod"]["optout"]>().toEqualTypeOf<"optional" | undefined>();
20+
});
21+
22+
test("ZodPreprocess input/output inference", () => {
23+
const pre = z.preprocess((v) => v, z.number().optional());
24+
expectTypeOf<z.output<typeof pre>>().toEqualTypeOf<number | undefined>();
25+
expectTypeOf<z.input<typeof pre>>().toEqualTypeOf<unknown>();
26+
});

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,47 @@ test("perform transform with non-fatal issues", () => {
280280
]]
281281
`);
282282
});
283+
284+
// https://github.com/colinhacks/zod/issues/5917
285+
test("optional propagates through preprocess inside object", () => {
286+
const outer = z.object({ x: z.preprocess((v) => v, z.number()).optional() });
287+
const inner = z.object({ x: z.preprocess((v) => v, z.number().optional()) });
288+
289+
expect(outer.safeParse({}).success).toBe(true);
290+
expect(inner.safeParse({}).success).toBe(true);
291+
292+
expect(outer.safeParse({ x: 1 })).toEqual({ success: true, data: { x: 1 } });
293+
expect(inner.safeParse({ x: 1 })).toEqual({ success: true, data: { x: 1 } });
294+
295+
expect(inner._zod.def.shape.x._zod.optin).toBe("optional");
296+
expect(inner._zod.def.shape.x._zod.optout).toBe("optional");
297+
});
298+
299+
test("preprocess is a structural subtype of ZodPipe", () => {
300+
const schema = z.preprocess((v) => v, z.string());
301+
expect(schema).toBeInstanceOf(z.ZodPipe);
302+
expect(schema).toBeInstanceOf(z.ZodPreprocess);
303+
expect(schema._zod.def.type).toBe("pipe");
304+
});
305+
306+
test("preprocess does not propagate values/propValues from inner schema", () => {
307+
const inner = z.preprocess((v) => v, z.literal("test"));
308+
expect(inner._zod.values).toBeUndefined();
309+
expect(inner._zod.propValues).toBeUndefined();
310+
});
311+
312+
test("preprocess as discriminator throws at construction (no propValues to inherit)", () => {
313+
const schema = z.discriminatedUnion("kind", [
314+
z.object({ kind: z.preprocess((v: any) => String(v).toUpperCase(), z.literal("A")), a: z.string() }),
315+
z.object({ kind: z.preprocess((v: any) => String(v).toUpperCase(), z.literal("B")), b: z.number() }),
316+
]);
317+
expect(() => schema.parse({ kind: "a", a: "x" })).toThrow(/Invalid discriminated union option/);
318+
});
319+
320+
test("preprocess as record key does not restrict accepted keys", () => {
321+
const schema = z.record(
322+
z.preprocess((v: any) => String(v).toLowerCase(), z.enum(["a", "b"])),
323+
z.string()
324+
);
325+
expect(schema.safeParse({ A: "x", B: "y" })).toEqual({ success: true, data: { a: "x", b: "y" } });
326+
});

packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2896,6 +2896,28 @@ test("use output type for preprocess", () => {
28962896
`);
28972897
});
28982898

2899+
test("strip output-side examples from input JSON schema for codec", () => {
2900+
const codec = z
2901+
.codec(z.string(), z.number(), { decode: (s) => Number(s), encode: (n) => String(n) })
2902+
.meta({ examples: [42] });
2903+
2904+
expect(z.toJSONSchema(codec, { io: "input" })).toMatchInlineSnapshot(`
2905+
{
2906+
"$schema": "https://json-schema.org/draft/2020-12/schema",
2907+
"type": "string",
2908+
}
2909+
`);
2910+
expect(z.toJSONSchema(codec, { io: "output" })).toMatchInlineSnapshot(`
2911+
{
2912+
"$schema": "https://json-schema.org/draft/2020-12/schema",
2913+
"examples": [
2914+
42,
2915+
],
2916+
"type": "number",
2917+
}
2918+
`);
2919+
});
2920+
28992921
// test("isTransforming", () => {
29002922
// const tx = z.core.isTransforming;
29012923
// expect(tx(z.string())).toEqual(false);

packages/zod/src/v4/core/json-schema-processors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,8 @@ export const catchProcessor: Processor<schemas.$ZodCatch> = (schema, ctx, json,
525525

526526
export const pipeProcessor: Processor<schemas.$ZodPipe> = (schema, ctx, _json, params) => {
527527
const def = schema._zod.def as schemas.$ZodPipeDef;
528-
const innerType = ctx.io === "input" ? (def.in._zod.def.type === "transform" ? def.out : def.in) : def.out;
528+
const inIsTransform = def.in._zod.traits.has("$ZodTransform");
529+
const innerType = ctx.io === "input" ? (inIsTransform ? def.out : def.in) : def.out;
529530
process(innerType, ctx as any, params);
530531
const seen = ctx.seen.get(schema)!;
531532
seen.ref = innerType;

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4118,6 +4118,37 @@ function handleCodecTxResult(left: ParsePayload, value: any, nextSchema: SomeTyp
41184118
return nextSchema._zod.run({ value, issues: left.issues }, ctx);
41194119
}
41204120

4121+
/////////////////////////////////////////////////
4122+
/////////////////////////////////////////////////
4123+
////////// //////////
4124+
////////// $ZodPreprocess //////////
4125+
////////// //////////
4126+
/////////////////////////////////////////////////
4127+
/////////////////////////////////////////////////
4128+
export interface $ZodPreprocessDef<B extends SomeType = $ZodType> extends $ZodPipeDef<$ZodTransform, B> {
4129+
in: $ZodTransform;
4130+
out: B;
4131+
}
4132+
4133+
export interface $ZodPreprocessInternals<B extends SomeType = $ZodType> extends $ZodPipeInternals<$ZodTransform, B> {
4134+
def: $ZodPreprocessDef<B>;
4135+
optin: B["_zod"]["optin"];
4136+
optout: B["_zod"]["optout"];
4137+
}
4138+
4139+
export interface $ZodPreprocess<B extends SomeType = $ZodType> extends $ZodPipe<$ZodTransform, B> {
4140+
_zod: $ZodPreprocessInternals<B>;
4141+
}
4142+
4143+
export const $ZodPreprocess: core.$constructor<$ZodPreprocess> = /*@__PURE__*/ core.$constructor(
4144+
"$ZodPreprocess",
4145+
(inst, def) => {
4146+
$ZodPipe.init(inst, def);
4147+
util.defineLazy(inst._zod, "optin", () => def.out._zod.optin);
4148+
util.defineLazy(inst._zod, "optout", () => def.out._zod.optout);
4149+
}
4150+
);
4151+
41214152
////////////////////////////////////////////
41224153
////////////////////////////////////////////
41234154
////////// //////////

packages/zod/src/v4/core/to-json-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ function isTransforming(
561561
return isTransforming(def.keyType, ctx) || isTransforming(def.valueType, ctx);
562562
}
563563
if (def.type === "pipe") {
564+
if (_schema._zod.traits.has("$ZodCodec")) return true;
564565
return isTransforming(def.in, ctx) || isTransforming(def.out, ctx);
565566
}
566567

0 commit comments

Comments
 (0)