Skip to content

Commit 53cec2a

Browse files
committed
fix(v4): resolve z.input past a preprocess transform
z.preprocess pipes a transform into a schema, so z.input handed back the bare transform — which validates nothing and re-runs the preprocessor: const P = z.preprocess((v) => String(v), z.string().min(5)); P.parse(12345); // "12345" — transform, then min(5) enforced z.input(P).parse(null); // "null" — accepts anything, min(5) gone A bare transform is not a real input side, so the schema it feeds is. That is the resolution toJSONSchema already makes for this exact case, and the two now agree on what the input side of a preprocess looks like. A pipe's own checks travel with whichever side wins, so a refine on a preprocess survives the projection. Codecs are untouched: they have two real sides, and z.input still returns `in` by identity.
1 parent 2125d30 commit 53cec2a

4 files changed

Lines changed: 56 additions & 5 deletions

File tree

packages/zod/src/v4/classic/in-out.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,26 @@ function withChecks(side: core.$ZodType, checks: core.$ZodTypeDef["checks"]): co
1414
return clone(side, mergeDefs(def, { checks: [...(def.checks ?? []), ...checks] }), { parent: true });
1515
}
1616

17-
/** Returns a copy of the schema with every pipe replaced by its input side. A pipe's own checks are dropped, since they constrain the decoded value the input side never produces. */
17+
/** The out side, carrying the pipe's own checks: those run against the decoded value, which is what `out` produces. */
18+
function outSide(def: core.$ZodPipeDef): core.$ZodType {
19+
return withChecks(def.out, def.checks);
20+
}
21+
22+
/** The in side. `z.preprocess` pipes a transform into a schema, and a bare transform validates nothing, so the schema it feeds is the real input side — the resolution `toJSONSchema` already makes for this case. */
23+
function inSide(def: core.$ZodPipeDef): core.$ZodType {
24+
return def.in._zod.traits.has("$ZodTransform") ? outSide(def) : def.in;
25+
}
26+
27+
/** Returns a copy of the schema with every pipe replaced by its input side. A codec's checks are dropped: they constrain the decoded value the input side never produces. */
1828
export function input<T extends core.$ZodType>(schema: T): schemas.ZodType<core.input<T>, core.input<T>> {
1929
return visit(schema, {
20-
pipe: (s) => s._zod.def.in,
30+
pipe: (s) => inSide(s._zod.def),
2131
}) as schemas.ZodType<core.input<T>, core.input<T>>;
2232
}
2333

2434
/** Returns a copy of the schema with every pipe replaced by its output side, carrying over the pipe's own checks. */
2535
export function output<T extends core.$ZodType>(schema: T): schemas.ZodType<core.output<T>, core.output<T>> {
2636
return visit(schema, {
27-
pipe: (s) => withChecks(s._zod.def.out, s._zod.def.checks),
37+
pipe: (s) => outSide(s._zod.def),
2838
}) as schemas.ZodType<core.output<T>, core.output<T>>;
2939
}

packages/zod/src/v4/classic/tests/in-out.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ describe("z.input / z.output", () => {
9090
expect(() => z.input(z.object({ s: z.string().min(2) })).parse({ s: "a" })).toThrow();
9191
});
9292

93+
test("z.input resolves past a preprocess transform, which is not a real input side", () => {
94+
const p = z.preprocess((v) => String(v), z.string().min(5));
95+
const encoded = z.input(p);
96+
97+
expect(encoded.parse("abcde")).toBe("abcde");
98+
// The bare transform used to come back here: it validated nothing and re-ran the preprocessor.
99+
expect(() => encoded.parse(1)).toThrow();
100+
expect(() => encoded.parse("abc")).toThrow();
101+
// The same resolution the JSON Schema emitter already makes for this schema.
102+
expect(z.toJSONSchema(p, { io: "input" })).toMatchObject({ type: "string", minLength: 5 });
103+
104+
// A check on the pipe itself travels with whichever side wins.
105+
const checked = z.preprocess((v) => String(v), z.string()).refine((s) => s.startsWith("ok"));
106+
expect(z.input(checked).parse("okay")).toBe("okay");
107+
expect(() => z.input(checked).parse("nope")).toThrow();
108+
109+
// Control: a codec has two real sides, so its input side is still `in`, by identity.
110+
const c = z.codec(z.string(), z.number(), { decode: Number, encode: String });
111+
expect(z.input(c)).toBe(c._zod.def.in);
112+
});
113+
93114
test("runtime `z.input` agrees with type-level `z.input<T>`", () => {
94115
const c = z.codec(z.string(), z.bigint(), {
95116
decode: (s) => BigInt(s),

packages/zod/src/v4/mini/in-out.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,26 @@ function withChecks(side: core.$ZodType, checks: core.$ZodTypeDef["checks"]): co
1414
return clone(side, mergeDefs(def, { checks: [...(def.checks ?? []), ...checks] }), { parent: true });
1515
}
1616

17+
/** See `classic/in-out.ts`. */
18+
function outSide(def: core.$ZodPipeDef): core.$ZodType {
19+
return withChecks(def.out, def.checks);
20+
}
21+
22+
/** See `classic/in-out.ts`. */
23+
function inSide(def: core.$ZodPipeDef): core.$ZodType {
24+
return def.in._zod.traits.has("$ZodTransform") ? outSide(def) : def.in;
25+
}
26+
1727
/** See `classic/in-out.ts`. */
1828
export function input<T extends core.$ZodType>(schema: T): schemas.ZodMiniType<core.input<T>, core.input<T>> {
1929
return visit(schema, {
20-
pipe: (s) => s._zod.def.in,
30+
pipe: (s) => inSide(s._zod.def),
2131
}) as schemas.ZodMiniType<core.input<T>, core.input<T>>;
2232
}
2333

2434
/** See `classic/in-out.ts`. */
2535
export function output<T extends core.$ZodType>(schema: T): schemas.ZodMiniType<core.output<T>, core.output<T>> {
2636
return visit(schema, {
27-
pipe: (s) => withChecks(s._zod.def.out, s._zod.def.checks),
37+
pipe: (s) => outSide(s._zod.def),
2838
}) as schemas.ZodMiniType<core.output<T>, core.output<T>>;
2939
}

packages/zod/src/v4/mini/tests/codec.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,13 @@ test("z.output carries checks attached to a pipe", () => {
558558
// The input side drops it: the check constrains a value that side never produces.
559559
expect(z.input(c).parse("5")).toBe("5");
560560
});
561+
562+
test("z.input resolves past a transform piped into a schema", () => {
563+
const p = z.pipe(
564+
z.transform((v: unknown) => String(v)),
565+
z.string().check(z.minLength(5))
566+
);
567+
568+
expect(z.input(p).parse("abcde")).toBe("abcde");
569+
expect(() => z.input(p).parse(1)).toThrow();
570+
});

0 commit comments

Comments
 (0)