zod
Version:
TypeScript-first schema declaration and validation library with static type inference
533 lines (481 loc) • 14.8 kB
text/typescript
import { describe, expect, test } from "vitest";
import * as z from "zod/v4";
describe("basic refinement functionality", () => {
test("should create a new schema instance when refining", () => {
const obj1 = z.object({
first: z.string(),
second: z.string(),
});
const obj2 = obj1.partial().strict();
const obj3 = obj2.refine((data) => data.first || data.second, "Either first or second should be filled in.");
expect(obj1 === (obj2 as any)).toEqual(false);
expect(obj2 === (obj3 as any)).toEqual(false);
});
test("should validate according to refinement logic", () => {
const schema = z
.object({
first: z.string(),
second: z.string(),
})
.partial()
.strict()
.refine((data) => data.first || data.second, "Either first or second should be filled in.");
// Should fail on empty object
expect(() => schema.parse({})).toThrow();
// Should pass with first property
expect(schema.parse({ first: "a" })).toEqual({ first: "a" });
// Should pass with second property
expect(schema.parse({ second: "a" })).toEqual({ second: "a" });
// Should pass with both properties
expect(schema.parse({ first: "a", second: "a" })).toEqual({ first: "a", second: "a" });
});
test("should validate strict mode correctly", () => {
const schema = z
.object({
first: z.string(),
second: z.string(),
})
.partial()
.strict();
// Should throw on extra properties
expect(() => schema.parse({ third: "adsf" })).toThrow();
});
});
describe("refinement with custom error messages", () => {
test("should use custom error message when validation fails", () => {
const validationSchema = z
.object({
email: z.string().email(),
password: z.string(),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, "Both password and confirmation must match");
const result = validationSchema.safeParse({
email: "aaaa@gmail.com",
password: "aaaaaaaa",
confirmPassword: "bbbbbbbb",
});
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues[0].message).toEqual("Both password and confirmation must match");
}
});
});
describe("async refinements", () => {
test("should support async refinement functions", async () => {
const validationSchema = z
.object({
email: z.string().email(),
password: z.string(),
confirmPassword: z.string(),
})
.refine(
(data) => Promise.resolve().then(() => data.password === data.confirmPassword),
"Both password and confirmation must match"
);
// Should pass with matching passwords
const validData = {
email: "aaaa@gmail.com",
password: "password",
confirmPassword: "password",
};
await expect(validationSchema.parseAsync(validData)).resolves.toEqual(validData);
// Should fail with non-matching passwords
await expect(
validationSchema.parseAsync({
email: "aaaa@gmail.com",
password: "password",
confirmPassword: "different",
})
).rejects.toThrow();
});
});
describe("early termination options", () => {
test("should abort early with continue: false", () => {
const schema = z
.string()
.superRefine((val, ctx) => {
if (val.length < 2) {
ctx.addIssue({
code: "custom",
message: "BAD",
continue: false,
});
}
})
.refine((_) => false);
const result = schema.safeParse("");
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues.length).toEqual(1);
expect(result.error.issues[0].message).toEqual("BAD");
}
});
test("should abort early with fatal: true", () => {
const schema = z
.string()
.superRefine((val, ctx) => {
if (val.length < 2) {
ctx.addIssue({
code: "custom",
fatal: true,
message: "BAD",
});
}
})
.refine((_) => false);
const result = schema.safeParse("");
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues.length).toEqual(1);
expect(result.error.issues[0].message).toEqual("BAD");
}
});
test("should abort early with abort flag", () => {
const schema = z
.string()
.refine((_) => false, { abort: true })
.refine((_) => false);
const result = schema.safeParse("");
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues.length).toEqual(1);
}
});
});
describe("custom error paths", () => {
test("should use custom path in error message", async () => {
const result = await z
.object({ password: z.string(), confirm: z.string() })
.refine((data) => data.confirm === data.password, { path: ["confirm"] })
.safeParse({ password: "asdf", confirm: "qewr" });
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(["confirm"]);
}
});
});
describe("superRefine functionality", () => {
test("should support multiple validation rules", () => {
const Strings = z.array(z.string()).superRefine((val, ctx) => {
if (val.length > 3) {
ctx.addIssue({
input: val,
code: "too_big",
origin: "array",
maximum: 3,
inclusive: true,
exact: true,
message: "Too many items 😡",
});
}
if (val.length !== new Set(val).size) {
ctx.addIssue({
input: val,
code: "custom",
message: `No duplicates allowed.`,
});
}
});
// Should fail with too many items and duplicates
const result = Strings.safeParse(["asfd", "asfd", "asfd", "asfd"]);
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues.length).toEqual(2);
expect(result.error.issues[0].message).toEqual("Too many items 😡");
expect(result.error.issues[1].message).toEqual("No duplicates allowed.");
}
// Should pass with valid input
const validArray = ["asfd", "qwer"];
expect(Strings.parse(validArray)).toEqual(validArray);
});
test("should support async superRefine", async () => {
const Strings = z.array(z.string()).superRefine(async (val, ctx) => {
if (val.length > 3) {
ctx.addIssue({
input: val,
code: "too_big",
origin: "array",
maximum: 3,
inclusive: true,
message: "Too many items 😡",
});
}
if (val.length !== new Set(val).size) {
ctx.addIssue({
input: val,
code: "custom",
message: `No duplicates allowed.`,
});
}
});
// Should fail with too many items and duplicates
const result = await Strings.safeParseAsync(["asfd", "asfd", "asfd", "asfd"]);
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues.length).toEqual(2);
}
// Should pass with valid input
const validArray = ["asfd", "qwer"];
await expect(Strings.parseAsync(validArray)).resolves.toEqual(validArray);
});
test("should accept string as shorthand for custom error message", () => {
const schema = z.string().superRefine((_, ctx) => {
ctx.addIssue("bad stuff");
});
const result = schema.safeParse("asdf");
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues).toHaveLength(1);
expect(result.error.issues[0].message).toEqual("bad stuff");
}
});
test("should respect fatal flag in superRefine", () => {
const schema = z
.string()
.superRefine((val, ctx) => {
if (val === "") {
ctx.addIssue({
input: val,
code: "custom",
message: "foo",
fatal: true,
});
}
})
.superRefine((val, ctx) => {
if (val !== " ") {
ctx.addIssue({
input: val,
code: "custom",
message: "bar",
});
}
});
const result = schema.safeParse("");
expect(result.success).toEqual(false);
if (!result.success) {
expect(result.error.issues.length).toEqual(1);
expect(result.error.issues[0].message).toEqual("foo");
}
});
});
describe("chained refinements", () => {
test("should collect all validation errors when appropriate", () => {
const objectSchema = z
.object({
length: z.number(),
size: z.number(),
})
.refine(({ length }) => length > 5, {
path: ["length"],
message: "length greater than 5",
})
.refine(({ size }) => size > 7, {
path: ["size"],
message: "size greater than 7",
});
// Should fail with one error
const r1 = objectSchema.safeParse({
length: 4,
size: 9,
});
expect(r1.success).toEqual(false);
if (!r1.success) {
expect(r1.error.issues.length).toEqual(1);
expect(r1.error.issues[0].path).toEqual(["length"]);
}
// Should fail with two errors
const r2 = objectSchema.safeParse({
length: 4,
size: 3,
});
expect(r2.success).toEqual(false);
if (!r2.success) {
expect(r2.error.issues.length).toEqual(2);
}
// Should pass with valid input
const validData = {
length: 6,
size: 8,
};
expect(objectSchema.parse(validData)).toEqual(validData);
});
});
// Commented tests can be uncommented once type-checking issues are resolved
/*
describe("type refinement", () => {
test("refinement type guard", () => {
const validationSchema = z.object({
a: z.string().refine((s): s is "a" => s === "a"),
});
type Input = z.input<typeof validationSchema>;
type Schema = z.infer<typeof validationSchema>;
expectTypeOf<Input["a"]>().not.toEqualTypeOf<"a">();
expectTypeOf<Input["a"]>().toEqualTypeOf<string>();
expectTypeOf<Schema["a"]>().toEqualTypeOf<"a">();
expectTypeOf<Schema["a"]>().not.toEqualTypeOf<string>();
});
test("superRefine - type narrowing", () => {
type NarrowType = { type: string; age: number };
const schema = z
.object({
type: z.string(),
age: z.number(),
})
.nullable()
.superRefine((arg, ctx): arg is NarrowType => {
if (!arg) {
// still need to make a call to ctx.addIssue
ctx.addIssue({
input: arg,
code: "custom",
message: "cannot be null",
fatal: true,
});
return false;
}
return true;
});
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<NarrowType>();
expect(schema.safeParse({ type: "test", age: 0 }).success).toEqual(true);
expect(schema.safeParse(null).success).toEqual(false);
});
test("chained mixed refining types", () => {
type firstRefinement = { first: string; second: number; third: true };
type secondRefinement = { first: "bob"; second: number; third: true };
type thirdRefinement = { first: "bob"; second: 33; third: true };
const schema = z
.object({
first: z.string(),
second: z.number(),
third: z.boolean(),
})
.nullable()
.refine((arg): arg is firstRefinement => !!arg?.third)
.superRefine((arg, ctx): arg is secondRefinement => {
expectTypeOf<typeof arg>().toEqualTypeOf<firstRefinement>();
if (arg.first !== "bob") {
ctx.addIssue({
input: arg,
code: "custom",
message: "`first` property must be `bob`",
});
return false;
}
return true;
})
.refine((arg): arg is thirdRefinement => {
expectTypeOf<typeof arg>().toEqualTypeOf<secondRefinement>();
return arg.second === 33;
});
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<thirdRefinement>();
});
});
*/
test("when", () => {
const schema = z
.strictObject({
password: z.string().min(8),
confirmPassword: z.string(),
other: z.string(),
})
.refine(
(data) => {
console.log("running check...");
console.log(data);
console.log(data.password);
return data.password === data.confirmPassword;
},
{
message: "Passwords do not match",
path: ["confirmPassword"],
when(payload) {
if (payload.value === undefined) return false;
if (payload.value === null) return false;
// no issues with confirmPassword or password
return payload.issues.every((iss) => iss.path?.[0] !== "confirmPassword" && iss.path?.[0] !== "password");
},
}
);
expect(schema.safeParse(undefined)).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"expected": "object",
"code": "invalid_type",
"path": [],
"message": "Invalid input: expected object, received undefined"
}
]],
"success": false,
}
`);
expect(schema.safeParse(null)).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"expected": "object",
"code": "invalid_type",
"path": [],
"message": "Invalid input: expected object, received null"
}
]],
"success": false,
}
`);
expect(
schema.safeParse({
password: "asdf",
confirmPassword: "asdfg",
other: "qwer",
})
).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"origin": "string",
"code": "too_small",
"minimum": 8,
"inclusive": true,
"path": [
"password"
],
"message": "Too small: expected string to have >=8 characters"
}
]],
"success": false,
}
`);
expect(
schema.safeParse({
password: "asdf",
confirmPassword: "asdfg",
other: 1234,
})
).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"origin": "string",
"code": "too_small",
"minimum": 8,
"inclusive": true,
"path": [
"password"
],
"message": "Too small: expected string to have >=8 characters"
},
{
"expected": "string",
"code": "invalid_type",
"path": [
"other"
],
"message": "Invalid input: expected string, received number"
}
]],
"success": false,
}
`);
});