import { expect, expectTypeOf, test } from "vitest"; import * as zm from "zod/mini"; import * as z from "zod/v4"; test("validate answers like safeParse.success", () => { expect(z.validate(z.string(), "asdf")).toBe(true); expect(z.validate(z.string(), 12)).toBe(false); expect(z.validate(z.number().int().min(0), 5)).toBe(true); expect(z.validate(z.number().int().min(0), -5)).toBe(false); const schema = z.object({ a: z.string(), b: z.array(z.number()) }); expect(z.validate(schema, { a: "x", b: [1, 2] })).toBe(true); expect(z.validate(schema, { a: "x", b: [1, "2"] })).toBe(false); expect(z.validate(schema, null)).toBe(false); }); test("validate runs transforms and refinements for the verdict only", () => { const schema = z .string() .transform((s) => s.length) .pipe(z.number().max(3)); expect(z.validate(schema, "ab")).toBe(true); expect(z.validate(schema, "abcd")).toBe(false); expect(z.validate(z.coerce.number(), "5")).toBe(true); }); test("validate narrows to the input type", () => { const value: unknown = "hi"; if (z.validate(z.string(), value)) { expectTypeOf(value).toEqualTypeOf(); } const transforming = z.string().transform((s) => s.length); if (z.validate(transforming, value)) { expectTypeOf(value).toEqualTypeOf(); } }); test("validate throws on async schemas; validateAsync handles them", async () => { const schema = z.string().refine(async (s) => s.length > 2); expect(() => z.validate(schema, "asdf")).toThrow(); await expect(z.validateAsync(schema, "asdf")).resolves.toBe(true); await expect(z.validateAsync(schema, "a")).resolves.toBe(false); await expect(z.validateAsync(z.string(), "a")).resolves.toBe(true); // a promise-returning callback that is not declared async compiles, so the compiled schema must stay off the fast path const compiled = z.compile(z.string().refine((s) => Promise.resolve(s.length > 1))); await expect(z.validateAsync(compiled, "abc")).resolves.toBe(true); await expect(z.validateAsync(compiled, "a")).resolves.toBe(false); }); test("validate agrees with the compiled fast path and keeps the callback bound", () => { let calls = 0; const schema = z.object({ name: z.string().refine((s) => { calls++; return s.length > 1; }), }); const compiled = z.compile(schema); calls = 0; expect(z.validate(compiled, { name: "ok" })).toBe(true); expect(calls).toBe(1); calls = 0; expect(z.validate(compiled, { name: "x" })).toBe(false); // a user callback can throw, so its rejection is not decidable and the fallback still runs expect(calls).toBeLessThanOrEqual(2); expect(z.validate(compiled, { name: 42 })).toBe(false); expect(z.validate(compiled, { name: "x" }, {})).toBe(false); expect(z.validate(schema, { name: "ok" })).toBe(true); }); test("the validate shortcut is taken for callback-free schemas only", () => { const definite = (s: z.ZodType) => (z.compile(s, { strict: true }) as any)._zod.bag.validator?.definite; // nothing here can throw, so a compiled rejection is proof the runtime would reject expect(definite(z.object({ a: z.string().min(1), b: z.number().max(3), c: z.email() }))).toBe(true); expect(definite(z.array(z.enum(["a", "b"])))).toBe(true); // each of these can answer INVALID for something the interpreter throws on expect(definite(z.object({ a: z.string().refine(() => true) }))).toBe(false); expect(definite(z.string().transform((v) => v))).toBe(false); expect(definite(z.custom(() => true))).toBe(false); expect(definite(z.number().catch(0))).toBe(false); expect(definite(z.lazy(() => z.string()))).toBe(false); expect( definite( z.intersection( z.number(), z.number().transform((x) => x + 1) ) ) ).toBe(false); expect( definite( z.record( z.string().transform((k) => k), z.number() ) ) ).toBe(false); }); test("compiled validate keeps the fallback where INVALID is not a decidable rejection", () => { // an intersection answers INVALID for an unmergeable merge, which the interpreter answers with a throw — a fast false would misreport a schema bug const unmergeable = z.compile( z.intersection( z.number(), z.number().transform((x) => x + 1) ), { strict: true } ); expect(() => z.validate(unmergeable, 1234)).toThrow("Unmergable intersection"); // a when-gated check islands at codegen, and an island answers INVALID for an async run too const gated = z.number().refine(() => Promise.resolve(true)); (gated._zod.def.checks![0]!._zod.def as { when?: unknown }).when = () => true; const compiled = z.compile(z.object({ n: gated }), { strict: true }); expect(() => z.validate(compiled, { n: 3 })).toThrow(z.core.$ZodAsyncError); expect(() => z.validate(z.object({ n: gated }), { n: 3 })).toThrow(z.core.$ZodAsyncError); // a plain function handing back a promise answers INVALID for union parity, so a transform is never a decidable rejection const piped = z.string().transform(() => Promise.resolve(1)); expect(() => z.validate(z.compile(piped, { strict: true }), "x")).toThrow(z.core.$ZodAsyncError); expect(() => z.validate(piped, "x")).toThrow(z.core.$ZodAsyncError); // a continuable child issue short-circuits the compiled intersection before its merge, but the interpreter reaches the merge and throws const continuable = z.intersection( z.number().min(10), z.number().transform((x) => x + 1) ); expect(() => z.validate(z.compile(continuable, { strict: true }), 1)).toThrow("Unmergable intersection"); expect(() => z.validate(continuable, 1)).toThrow("Unmergable intersection"); }); test("compiled validate agrees with the interpreter, verdict and throw alike", () => { // guards the `definite` flag: a new generator that answers INVALID for something the interpreter throws on must clear it, or this fails const thenable = () => Promise.resolve(1) as never; const schemas: z.ZodType[] = [ z.object({ a: z.string(), b: z.number().min(1) }), z.strictObject({ a: z.string() }), z.array(z.string().min(1)), z.tuple([z.string()], z.number()), z.record(z.string(), z.number()), // a record key compiles in its own context, so its definiteness has to be carried out z.record(z.string().transform(thenable), z.number()), z.record(z.email(), z.number()), z.union([z.string(), z.number()]), z.intersection( z.number(), z.number().transform((x) => x + 1) ), z.intersection( z.number().min(10), z.number().transform((x) => x + 1) ), // unmergeable with no user callback in it, so only the intersection itself can clear the flag z.intersection(z.string().default("a"), z.string().default("b")), z.lazy(() => z.object({ a: z.string() })), z.number().catch(0), z.nonoptional(z.string().optional()), z.map(z.string(), z.number()), z.set(z.string().min(2)), z.templateLiteral(["a", z.number()]), z.coerce.number(), z.stringbool(), z.string().transform(thenable), z.union([z.string().transform(thenable), z.number()]), z.array(z.string().transform(thenable)), z.custom(thenable), z.string().refine(thenable), z.string().pipe(z.string().min(3)), // a codec's decode is the pipe branch's transform, and a plain function can still hand back a promise z.codec(z.string(), z.number(), { decode: thenable, encode: String }), z.object({ first: z.string(), second: z.codec(z.string(), z.number(), { decode: thenable, encode: String }) }), // a continuable check does not settle the interpreter's walk, so it reaches a throwing callback the compiled path short-circuits past — the first-failure exit does not cover the check chain z .string() .min(5) .refine(() => { throw new RangeError("u"); }), z .string() .min(5) .superRefine(() => { throw new RangeError("u"); }), // a user callback can throw, and compiled code can reject an earlier sibling before ever reaching it z.object({ first: z.string(), second: z.string().refine(() => { throw new RangeError("u"); }), }), z.object({ first: z.string(), second: z.custom(() => { throw new TypeError("u"); }), }), // superRefine and check take the other branch of the refine generator than refine does z.object({ first: z.string(), second: z.string().superRefine(() => { throw new RangeError("u"); }), }), z.string().superRefine(thenable as never), z.object({ first: z.string(), second: z.string().transform(() => { throw new RangeError("u"); }), }), z.object({ first: z.string(), second: z.custom(thenable) }), z.tuple([ z.string(), z.number().catch(() => { throw new RangeError("u"); }), ]), ]; const inputs = [ "x", "ab", "", 0, 1, Number.NaN, true, null, undefined, {}, { a: "x" }, { a: 1 }, [], ["x"], new Map(), new Set(["a"]), "true", // the exposing shape: an earlier field the compiled path rejects before it reaches the throwing one { first: 1, second: "s" }, { first: "s", second: "s" }, { first: 1 }, [1, "x"], ["s", "x"], ]; const outcome = (fn: () => unknown) => { try { return `ok:${fn()}`; } catch (err) { return `throw:${(err as Error)?.constructor?.name}`; } }; for (const schema of schemas) { let compiled: z.ZodType; try { compiled = z.compile(schema, { strict: true }); } catch { continue; // refused at codegen, so there is no fast path to disagree } for (const input of inputs) { expect( outcome(() => z.validate(compiled, input)), `${JSON.stringify(input)} on ${schema._zod.def.type}` ).toBe(outcome(() => z.validate(schema, input))); } } }); test("validate is exported from zod/mini", () => { expect(zm.validate(zm.string(), "a")).toBe(true); expect(zm.validate(zm.string(), 1)).toBe(false); expect(zm.validate(zm.object({ a: zm.number() }), { a: 1 })).toBe(true); }); test("the validate method matches the top-level function", () => { expect(z.string().validate("asdf")).toBe(true); expect(z.string().validate(12)).toBe(false); expect(z.object({ a: z.string() }).validate({ a: "x" })).toBe(true); expect(z.object({ a: z.string() }).validate({ a: 1 })).toBe(false); expect(z.compile(z.object({ a: z.string() })).validate({ a: "x" })).toBe(true); }); test("the validate method narrows to the input type", () => { const value: unknown = "hi"; if (z.string().validate(value)) { expectTypeOf(value).toEqualTypeOf(); } const transforming = z.string().transform((s) => s.length); if (transforming.validate(value)) { expectTypeOf(value).toEqualTypeOf(); expectTypeOf(value).not.toEqualTypeOf(); } }); test("the validate method takes a params argument", () => { let calls = 0; const error = () => { calls++; return "bad"; }; expect(z.string().validate(12, { error })).toBe(false); expect(z.string().validate("ok", { error })).toBe(true); // a ctx defeats the compiled definite shortcut, so this is the fallback answering expect(z.compile(z.string()).validate(12, { error })).toBe(false); expect(z.string().validate("ok", { jitless: true })).toBe(true); // no issue is ever finalized, so an error map never runs expect(calls).toBe(0); }); test("the validateAsync method handles async schemas the method form throws on", async () => { const schema = z.string().refine(async (s) => s.length > 2); expect(() => schema.validate("asdf")).toThrow(z.core.$ZodAsyncError); await expect(schema.validateAsync("asdf")).resolves.toBe(true); await expect(schema.validateAsync("a")).resolves.toBe(false); await expect(z.string().validateAsync(12)).resolves.toBe(false); }); test("zod/mini has no validate method", () => { const schema = zm.string(); expect("parse" in schema).toBe(true); expect("validate" in schema).toBe(false); expect("validateAsync" in schema).toBe(false); }); // counts reads of each child slot, so a stopped walk is observable without putting a callback in the schema — a refinement spy would work too, but reading the input says nothing about what ran function counted( slots: Record, bad: Record = {} ): [Record, () => number] { let reads = 0; const obj: Record = { ...bad }; for (const [k, v] of Object.entries(slots)) { Object.defineProperty(obj, k, { enumerable: true, get() { reads++; return v; }, }); } return [obj, () => reads]; } test("validate stops a container at its first aborting issue", () => { const shape = { a: z.number(), b: z.string(), c: z.string() }; for (const jitless of [false, true]) { const label = jitless ? "interpreted" : "compiled"; const [input, reads] = counted({ b: "x", c: "x" }, { a: "no" }); expect(z.validate(z.object(shape), input, { jitless }), label).toBe(false); expect(reads(), `${label}: keys read after the first abort`).toBe(0); const [ok, okReads] = counted({ b: "x", c: "x" }, { a: 1 }); expect(z.validate(z.object(shape), ok, { jitless }), label).toBe(true); expect(okReads(), `${label}: a valid parse still reads every key`).toBe(2); } // an array stops at the first bad element const arr: unknown[] = [1]; let elementReads = 0; for (const i of [1, 2]) { Object.defineProperty(arr, i, { enumerable: true, get() { elementReads++; return "x"; }, }); } expect(z.validate(z.array(z.string()), arr)).toBe(false); expect(elementReads, "elements read after the first abort").toBe(0); // the shape phase's abort carries across into the catchall const [co, coReads] = counted({ b: "x" }, { a: "no" }); expect(z.validate(z.object({ a: z.number() }).catchall(z.string()), co)).toBe(false); expect(coReads(), "catchall keys read after the shape aborted").toBe(0); // and a tuple's fixed items carry into its rest const tup: unknown[] = ["no"]; let restReads = 0; for (const i of [1, 2]) { Object.defineProperty(tup, i, { enumerable: true, get() { restReads++; return "x"; }, }); } expect(z.validate(z.tuple([z.number()], z.string()), tup)).toBe(false); expect(restReads, "rest elements read after a fixed item aborted").toBe(0); }); test("a default provider runs only when the key is absent", () => { const boom = (): never => { throw new RangeError("boom"); }; // the provider sits behind an accessor, so reading the def would call it for (const [name, schema] of [ ["default", z.object({ a: z.number(), b: z.string().default(boom as any) })], ["prefault", z.object({ a: z.number(), b: z.string().prefault(boom as any) })], ] as const) { expect(z.validate(schema, { a: "bad", b: "ok" }), name).toBe(false); expect(z.validate(schema, { a: 1, b: "ok" }), name).toBe(true); } // absent, the provider does run, and its throw is the parse's own expect(() => z.validate(z.object({ b: z.string().default(boom as any) }), {})).toThrow(RangeError); }); test("the first failure stops the walk whatever follows it", () => { // a sibling after the failure is never parsed, so its refinement never runs let calls = 0; const spy = z.string().refine(() => { calls++; return true; }); expect(z.validate(z.object({ a: z.number(), b: spy, c: spy }), { a: "no", b: "x", c: "x" })).toBe(false); expect(calls).toBe(0); // reads of the trailing key say whether the walk stopped, without putting a callback in the schema const skips = (b: z.ZodType, value: unknown = "x") => { let reads = 0; const input: Record = { a: "no" }; Object.defineProperty(input, "b", { enumerable: true, get() { reads++; return value; }, }); z.validate(z.object({ a: z.number(), b }), input); return reads === 0; }; // the trailing key is skipped no matter what it holds -- user code included, since it is never reached for (const [name, b, value] of [ ["plain", z.string(), "x"], ["min", z.string().min(2), "x"], ["email", z.email(), "x"], ["array", z.array(z.string().min(2)), ["ab"]], ["error map", z.string({ error: () => "nope" }), "x"], ["transform", z.string().transform((v) => v), "x"], ["refine", z.string().refine(() => true), "x"], ["custom", z.custom(() => true), "x"], ["catch", z.string().catch("x"), "x"], ["lazy", z.lazy(() => z.string()), "x"], ["coerce", z.coerce.number(), 1], ] as [string, z.ZodType, unknown][]) { expect(skips(b, value), name).toBe(true); } }); test("a continuable issue never stops the walk", () => { // .min() is continuable, so a surrounding schema can still reconcile it and the guard must not stop let calls = 0; const el = z .string() .min(5) .refine(() => { calls++; return true; }); expect(z.validate(z.array(el), ["a", "b", "c"])).toBe(false); expect(calls).toBe(3); }); test("z.record keeps walking under validate", () => { // deliberate asymmetry: a record's invalid_key aborts, but an enclosing intersection reconciles it against the sibling operand, so a stopped loop hides keys the sibling does not own let reads = 0; const input: Record = { a: 1 }; for (const k of ["b", "c"]) { Object.defineProperty(input, k, { enumerable: true, get() { reads++; return "x"; }, }); } expect(z.validate(z.record(z.string(), z.string()), input)).toBe(false); expect(reads).toBe(2); }); test("validate matches safeParse where an issue can still be reconciled away", async () => { const recA = z.record(z.string().regex(/^a/), z.any()); const recB = z.record(z.string().regex(/^b/), z.any()); const schemas: z.ZodType[] = [ z.intersection(recA, recB), z.intersection(recA.pipe(z.any()), recB.pipe(z.any())), z.intersection(z.strictObject({ a: z.string() }), z.strictObject({ b: z.number() })), z.intersection(z.array(z.string()), z.array(z.string().min(2))), z.strictObject({ a: z.string() }).pipe(z.any()), z.union([z.strictObject({ a: z.string() }), z.strictObject({ a: z.string(), b: z.number() })]), z.array(z.string()).catch([]), z.object({ a: z.string() }).optional(), ]; const inputs: unknown[] = [ { a1: 1, b1: 2 }, { a1: 1, b1: 2, zz: 3 }, { a1: 1, zz: 2, b1: 3, yy: 4 }, { b1: 1, a1: 2, zz: 3, yy: 4 }, { a: "x", b: 1 }, { a: "x", b: 1, c: 9 }, { a: "x" }, ["ab", "cd"], ["a", "b"], undefined, ]; const outcome = (fn: () => unknown) => { try { return `ok:${fn()}`; } catch (err) { return `throw:${(err as Error)?.constructor?.name}`; } }; const outcomeAsync = async (fn: () => Promise) => { try { return `ok:${await fn()}`; } catch (err) { return `throw:${(err as Error)?.constructor?.name}`; } }; for (const schema of schemas) { for (const input of inputs) { const label = `${schema._zod.def.type} on ${JSON.stringify(input)}`; expect( outcome(() => z.validate(schema, input)), label ).toBe(outcome(() => schema.safeParse(input).success)); expect(await outcomeAsync(() => z.validateAsync(schema, input)), label).toBe( await outcomeAsync(async () => (await schema.safeParseAsync(input)).success) ); } } }); test("abortEarly does not leak into safeParse", () => { const schema = z.object({ a: z.string(), b: z.string(), c: z.string() }); expect(schema.safeParse({ a: 1, b: 2, c: 3 }).error!.issues).toHaveLength(3); expect(z.validate(schema, { a: 1, b: 2, c: 3 })).toBe(false); expect(schema.safeParse({ a: 1, b: 2, c: 3 }).error!.issues).toHaveLength(3); }); test("a callback after the first failure never runs, so validate answers where safeParse throws", () => { const boom = (): never => { throw new RangeError("boom"); }; // the deliberate divergence, in every shape that can carry a callback: an earlier sibling settles the boolean, so the later throw is never reached const lateSym = Symbol("late"); const cases: [string, z.ZodType, unknown][] = [ ["object transform", z.object({ a: z.number(), b: z.string().transform(boom) }), { a: "bad", b: "ok" }], ["object refine", z.object({ a: z.number(), b: z.string().refine(boom) }), { a: "bad", b: "ok" }], ["object custom", z.object({ a: z.number(), b: z.custom(boom) }), { a: "bad", b: "ok" }], // these three hang the callback off `_zod.check` rather than the def ["object check", z.object({ a: z.number(), b: z.string().check(boom) }), { a: "bad", b: "ok" }], ["object superRefine", z.object({ a: z.number(), b: z.string().superRefine(boom) }), { a: "bad", b: "ok" }], ["object overwrite", z.object({ a: z.number(), b: z.string().overwrite(boom as any) }), { a: "bad", b: "ok" }], // the input must carry the same symbol with a value the field accepts, or the transform never runs and the row asserts nothing [ "symbol key", z.object({ a: z.number(), [lateSym]: z.string().transform(boom) } as any), { a: "bad", [lateSym]: "ok" }, ], ["object catch", z.object({ a: z.number(), b: z.string().catch(boom) }), { a: "bad", b: 1 }], ["object lazy", z.object({ a: z.number(), b: z.lazy(() => z.string().transform(boom)) }), { a: "bad", b: "ok" }], ["array", z.array(z.union([z.number(), z.string().transform(boom)])), [true, "throws"]], ["tuple rest", z.tuple([z.number()], z.string().transform(boom)), ["bad", "throws"]], ["catchall", z.object({ a: z.number() }).catchall(z.string().transform(boom)), { a: "bad", b: "ok" }], ["set", z.set(z.union([z.number(), z.string().transform(boom)])), new Set([true, "throws"])], [ "map", z.map(z.string(), z.union([z.number(), z.string().transform(boom)])), new Map([ [1, 0], ["k", "throws"], ]), ], ]; const outcome = (fn: () => unknown) => { try { return `ok:${fn()}`; } catch (err) { return `throw:${(err as Error)?.constructor?.name}`; } }; for (const [name, schema, input] of cases) { // safeParse walks the whole tree and reaches the throw; validate settled the answer at the earlier sibling and never runs it expect( outcome(() => schema.safeParse(input).success), `${name}: safeParse` ).toBe("throw:RangeError"); expect( outcome(() => z.validate(schema, input)), `${name}: validate` ).toBe("ok:false"); } }); test("the stop lands between children, not inside one", () => { const boom = (): never => { throw new RangeError("boom"); }; // both parse as a unit, so an abort in one half cannot skip the other; stopping inside either risks a discarded issue turning a false into a true const pairs: [string, z.ZodType, unknown][] = [ ["map entry", z.map(z.number(), z.string().transform(boom)), new Map([["bad", "ok"]])], ["tuple fixed item", z.tuple([z.number(), z.string().transform(boom)]), ["bad", "ok"]], ]; for (const [name, schema, input] of pairs) { expect(() => z.validate(schema, input), name).toThrow(RangeError); expect(() => schema.safeParse(input), `${name}: safeParse agrees`).toThrow(RangeError); } // between children, the stop does happen expect(z.validate(z.object({ a: z.number(), b: z.string().transform(boom) }), { a: "bad", b: "ok" })).toBe(false); expect(z.validate(z.array(z.union([z.number(), z.string().transform(boom)])), [true, "ok"])).toBe(false); }); test("validate rethrows a callback it actually reaches", () => { const boom = (): never => { throw new RangeError("boom"); }; // nothing rejects before the callback, so validate reaches it and the throw is the caller's const cases: [string, z.ZodType, unknown][] = [ ["transform", z.object({ a: z.string().transform(boom), b: z.number() }), { a: "ok", b: "bad" }], ["refine", z.object({ a: z.string().refine(boom), b: z.number() }), { a: "ok", b: "bad" }], ["check", z.object({ a: z.string().check(boom), b: z.number() }), { a: "ok", b: "bad" }], ["array element", z.array(z.string().transform(boom)), ["ok", "ok"]], ["catchall", z.object({}).catchall(z.string().transform(boom)), { a: "ok" }], ["set", z.set(z.string().transform(boom)), new Set(["ok"])], ["map", z.map(z.string(), z.string().transform(boom)), new Map([["k", "ok"]])], ["tuple rest", z.tuple([z.string()], z.string().transform(boom)), ["ok", "ok"]], ]; for (const [name, schema, input] of cases) { expect(() => z.validate(schema, input), name).toThrow(RangeError); } });