import { describe, expect, test } from "vitest"; import * as z from "zod"; describe("z.input / z.output", () => { test("flat codec: input/output give the right side", () => { const c = z.codec(z.string(), z.bigint(), { decode: (s) => BigInt(s), encode: (n) => n.toString(), }); expect(z.input(c).parse("1")).toBe("1"); expect(z.output(c).parse(1n)).toBe(1n); }); test("codec nested in object: output works recursively (regression #5224)", () => { const c = z.codec(z.string(), z.bigint(), { decode: (s) => BigInt(s), encode: (n) => n.toString(), }); const obj = z.object({ b: c }); const decoded = z.output(obj); expect(decoded.parse({ b: 1n })).toEqual({ b: 1n }); const encoded = z.input(obj); expect(encoded.parse({ b: "1" })).toEqual({ b: "1" }); }); test("nested across record / map: matches the use case in #5224", () => { const keyCodec = z.codec(z.string(), z.enum(["a", "b"]), { decode: (s) => (s === "a" || s === "b" ? s : z.NEVER), encode: (s) => s, }); const valueObject = z.object({ n: z.codec(z.string(), z.bigint(), { decode: (s) => BigInt(s), encode: (n) => n.toString() }), }); const fullCodec = z.codec( z.record(z.input(keyCodec), z.input(valueObject)), z.map(z.output(keyCodec), z.output(valueObject)), { decode: (input) => { const m = new Map<"a" | "b", { n: bigint }>(); for (const [k, v] of Object.entries(input)) { m.set(keyCodec.decode(k), valueObject.decode(v)); } return m; }, encode: (input) => { const out: Record = {}; for (const [k, v] of input.entries()) { out[keyCodec.encode(k)] = valueObject.encode(v); } return out; }, } ); expect(fullCodec.decode({ a: { n: "1" } })).toEqual(new Map([["a", { n: 1n }]])); expect(fullCodec.encode(new Map([["b", { n: 2n }]]))).toEqual({ b: { n: "2" } }); }); test("schema without pipes is returned with identity (no clone)", () => { const s = z.object({ a: z.string() }); expect(z.input(s)).toBe(s); expect(z.output(s)).toBe(s); }); test("z.output carries checks attached to a pipe", () => { const c = z .codec(z.string(), z.number().int(), { decode: Number, encode: String }) .refine((n) => n > 10, { error: "gt10" }); const obj = z.object({ list: z.array(c) }); const decoded = z.output(obj); expect(decoded.parse({ list: [50] })).toEqual({ list: [50] }); // The pipe's own check and the one already on its out side both survive, in that order. expect(() => decoded.parse({ list: [5] })).toThrow("gt10"); expect(() => decoded.parse({ list: [50.5] })).toThrow(); // A pipe's checks run on the decoded value in both directions, so encode enforces them too. expect(() => decoded.encode({ list: [5] })).toThrow("gt10"); // Carrying them clones the out side rather than mutating it. expect(c._zod.def.out._zod.def.checks).toHaveLength(1); }); test("z.input drops a pipe's checks, which constrain a value the input side never produces", () => { const c = z.codec(z.string(), z.number(), { decode: Number, encode: String }).refine((n) => n > 10); expect(z.input(c).parse("5")).toBe("5"); // Control: a check that isn't attached to a pipe is left alone. expect(() => z.input(z.object({ s: z.string().min(2) })).parse({ s: "a" })).toThrow(); }); test("z.input resolves past a preprocess transform, which is not a real input side", () => { const p = z.preprocess((v) => String(v), z.string().min(5)); const encoded = z.input(p); expect(encoded.parse("abcde")).toBe("abcde"); // The bare transform used to come back here: it validated nothing and re-ran the preprocessor. expect(() => encoded.parse(1)).toThrow(); expect(() => encoded.parse("abc")).toThrow(); // The same resolution the JSON Schema emitter already makes for this schema. expect(z.toJSONSchema(p, { io: "input" })).toMatchObject({ type: "string", minLength: 5 }); // A check on the pipe itself travels with whichever side wins. const checked = z.preprocess((v) => String(v), z.string()).refine((s) => s.startsWith("ok")); expect(z.input(checked).parse("okay")).toBe("okay"); expect(() => z.input(checked).parse("nope")).toThrow(); // Control: a codec has two real sides, so its input side is still `in`, by identity. const c = z.codec(z.string(), z.number(), { decode: Number, encode: String }); expect(z.input(c)).toBe(c._zod.def.in); }); test("a wrapper's stored value survives only on the side it belongs to", () => { const c = z.codec(z.string(), z.number(), { decode: Number, encode: String }); // A default and a catch hold output-side values, so the input projection sheds them. expect(z.input(c.default(7)).parse(undefined)).toBe(undefined); expect(z.input(c.default(7)).parse("1")).toBe("1"); expect(() => z.input(c.catch(9)).parse(123 as any)).toThrow(); expect(z.input(c.catch(9)).parse("1")).toBe("1"); expect(z.output(c.default(7)).parse(undefined)).toBe(7); expect(z.output(c.catch(9)).parse("zz" as any)).toBe(9); // A prefault is fed through the schema, so it is input-side and the output projection sheds it. expect(z.input(c.prefault("5")).parse(undefined)).toBe("5"); expect(() => z.output(c.prefault("5")).parse(undefined as any)).toThrow(); expect(z.output(c.prefault("5")).parse(3)).toBe(3); }); test("a wrapper over a pipe-free schema keeps both its value and its identity", () => { const d = z.string().default("x"); const k = z.string().catch("y"); const f = z.string().prefault("z"); expect(z.input(d)).toBe(d); expect(z.input(d).parse(undefined)).toBe("x"); expect(z.input(k)).toBe(k); expect(z.output(f)).toBe(f); const obj = z.object({ a: z.string().default("q") }); expect(z.input(obj)).toBe(obj); expect(z.input(obj).parse({})).toEqual({ a: "q" }); }); test("runtime `z.input` agrees with type-level `z.input`", () => { const c = z.codec(z.string(), z.bigint(), { decode: (s) => BigInt(s), encode: (n) => n.toString(), }); const obj = z.object({ b: c }); // Runtime: parse the input shape. const parsed = z.input(obj).parse({ b: "1" }); // Type: matches the declared input type. const asType: z.input = parsed; expect(asType).toEqual({ b: "1" }); }); });