646 lines
24 KiB
Plaintext
646 lines
24 KiB
Plaintext
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);
|
|
}
|
|
});
|