zod
Version:
TypeScript-first schema declaration and validation library with static type inference
646 lines (598 loc) • 25 kB
text/typescript
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<string>();
}
const transforming = z.string().transform((s) => s.length);
if (z.validate(transforming, value)) {
expectTypeOf(value).toEqualTypeOf<string>();
}
});
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<string>();
}
const transforming = z.string().transform((s) => s.length);
if (transforming.validate(value)) {
expectTypeOf(value).toEqualTypeOf<string>();
expectTypeOf(value).not.toEqualTypeOf<number>();
}
});
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<string, unknown>,
bad: Record<string, unknown> = {}
): [Record<string, unknown>, () => number] {
let reads = 0;
const obj: Record<string, unknown> = { ...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<string, unknown> = { 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<string, unknown> = { 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<unknown>) => {
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<any, any>([
[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);
}
});