Overview
Zod provides two methods for custom validation:
.refine() - Simple custom validation with a boolean check
.superRefine() - Advanced validation with full control over error messages and multiple issues
Both methods run after the base schema validation succeeds.
.refine()
Add custom validation logic with a simple check function.
Signature
schema.refine(
(data) => boolean | Promise<boolean>,
options?
)
check
(data) => boolean | Promise<boolean>
required
Function that returns true if validation passes, false if it fails
options
string | RefinementOptions
Error message string or options object
Returns: The schema with refinement applied
Basic Usage
const schema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
"Passwords must match"
);
schema.parse({
password: "password123",
confirmPassword: "password123"
}); // ✅ Valid
schema.parse({
password: "password123",
confirmPassword: "different"
}); // ❌ Error: "Passwords must match"
Custom Error Paths
Specify which field the error applies to:
const schema = z.object({
password: z.string(),
confirm: z.string(),
}).refine(
(data) => data.confirm === data.password,
{
message: "Passwords must match",
path: ["confirm"], // Error appears on confirm field
}
);
const result = schema.safeParse({
password: "pass123",
confirm: "different"
});
if (!result.success) {
console.log(result.error.issues[0].path);
// ["confirm"]
}
Async Refinements
const schema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email);
return !exists;
},
"Email is already taken"
);
// Must use async parsing
const result = await schema.safeParseAsync("[email protected]");
.superRefine()
Advanced validation with full control over error reporting.
Signature
schema.superRefine(
(data, ctx) => void | Promise<void>
)
Context object with addIssue() method
Returns: The schema with refinement applied
Basic Usage
const schema = z.array(z.string()).superRefine((val, ctx) => {
if (val.length > 3) {
ctx.addIssue({
code: "too_big",
maximum: 3,
inclusive: true,
message: "Too many items",
});
}
if (val.length !== new Set(val).size) {
ctx.addIssue({
code: "custom",
message: "No duplicates allowed",
});
}
});
const result = schema.safeParse(["a", "a", "b", "c"]);
// Two errors:
// - "Too many items"
// - "No duplicates allowed"
Adding Issues
The ctx.addIssue() method accepts several forms:
Full Issue Object
schema.superRefine((data, ctx) => {
ctx.addIssue({
code: "custom",
message: "Validation failed",
path: ["fieldName"], // Optional: specific field
fatal: true, // Optional: stop validation
continue: false, // Optional: don't run next checks
});
});
String Shorthand
schema.superRefine((data, ctx) => {
ctx.addIssue("Something went wrong");
});
Refinement Options
Error Messages
// Simple string message
schema.refine((val) => val.length > 0, "Cannot be empty");
// Message in options
schema.refine(
(val) => val.length > 0,
{ message: "Cannot be empty" }
);
Error Paths
const schema = z.object({
start: z.date(),
end: z.date(),
}).refine(
(data) => data.end > data.start,
{
message: "End date must be after start date",
path: ["end"],
}
);
Aborting Validation
Stop validation on first error:
const schema = z.string()
.refine((s) => s.length > 5, {
message: "Too short",
abort: true, // Stop here if this fails
})
.refine((s) => /[A-Z]/.test(s), "Must contain uppercase");
const result = schema.safeParse("abc");
// Only "Too short" error, second refinement is skipped
Fatal Flag
Similar to abort, but set on the issue:
schema.superRefine((val, ctx) => {
if (val === "") {
ctx.addIssue({
code: "custom",
message: "Required",
fatal: true, // Stop validation
});
}
});
Continue Flag
Control whether subsequent refinements run:
// Default: continue after error
const defaultBehavior = z.string()
.superRefine((_, ctx) => {
ctx.addIssue({ code: "custom", message: "First" });
})
.refine(() => false, "Second");
defaultBehavior.safeParse("test");
// Both errors reported
// Explicit: don't continue
const stopOnError = z.string()
.superRefine((_, ctx) => {
ctx.addIssue({
code: "custom",
message: "First",
continue: false // Stop here
});
})
.refine(() => false, "Second");
stopOnError.safeParse("test");
// Only "First" error
Conditional Refinements
Use the when option to conditionally run refinements:
const schema = z.strictObject({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords do not match",
path: ["confirmPassword"],
when(payload) {
// Only run if no errors on password fields
return payload.issues.every(
(iss) => iss.path?.[0] !== "confirmPassword" &&
iss.path?.[0] !== "password"
);
},
}
);
Type Narrowing
Type Guard Refinements
Use type guards to narrow the output type:
const schema = z.string().refine(
(s): s is "admin" | "user" => s === "admin" || s === "user"
);
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // "admin" | "user"
Non-Type-Guard Refinements
Regular refinements don’t narrow types:
const schema = z.string().refine((s) => s.length > 0);
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // string (not narrowed)
Common Patterns
Either/Or Fields
const schema = z.object({
email: z.string().email().optional(),
phone: z.string().optional(),
}).refine(
(data) => data.email || data.phone,
"Either email or phone is required"
);
Cross-Field Validation
const dateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(
(data) => data.endDate >= data.startDate,
{
message: "End date must be after or equal to start date",
path: ["endDate"],
}
);
Unique Array Elements
const uniqueArraySchema = z.array(z.string()).superRefine((arr, ctx) => {
const seen = new Set<string>();
arr.forEach((item, index) => {
if (seen.has(item)) {
ctx.addIssue({
code: "custom",
message: `Duplicate value: ${item}`,
path: [index],
});
}
seen.add(item);
});
});
Complex Business Rules
const orderSchema = z.object({
items: z.array(z.object({
price: z.number(),
quantity: z.number(),
})),
discount: z.number().min(0).max(100),
}).superRefine((order, ctx) => {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
if (subtotal < 50 && order.discount > 10) {
ctx.addIssue({
code: "custom",
message: "Discount over 10% requires minimum $50 order",
path: ["discount"],
});
}
});
Async Database Validation
const usernameSchema = z.string()
.min(3)
.refine(
async (username) => {
const exists = await db.users.findOne({ username });
return !exists;
},
"Username is already taken"
);
// Must use async parsing
await usernameSchema.parseAsync("newuser");
Multiple Validation Rules
const passwordSchema = z.string()
.min(8, "At least 8 characters")
.superRefine((password, ctx) => {
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
code: "custom",
message: "Must contain uppercase letter",
});
}
if (!/[a-z]/.test(password)) {
ctx.addIssue({
code: "custom",
message: "Must contain lowercase letter",
});
}
if (!/[0-9]/.test(password)) {
ctx.addIssue({
code: "custom",
message: "Must contain number",
});
}
});
Chaining Refinements
You can chain multiple refinements:
const schema = z.object({
length: z.number(),
width: z.number(),
height: z.number(),
})
.refine(
({ length }) => length > 0,
{ message: "Length must be positive", path: ["length"] }
)
.refine(
({ width }) => width > 0,
{ message: "Width must be positive", path: ["width"] }
)
.refine(
({ height }) => height > 0,
{ message: "Height must be positive", path: ["height"] }
);
// All errors are collected
const result = schema.safeParse({ length: -1, width: -1, height: -1 });
// Three errors reported
Error Examples
Basic Refinement Error
const schema = z.object({
password: z.string(),
confirm: z.string(),
}).refine(
(data) => data.password === data.confirm,
"Passwords must match"
);
const result = schema.safeParse({
password: "pass123",
confirm: "different"
});
if (!result.success) {
console.log(result.error.issues);
// [
// {
// code: "custom",
// message: "Passwords must match",
// path: []
// }
// ]
}
SuperRefine Multiple Errors
const schema = z.array(z.string()).superRefine((val, ctx) => {
if (val.length > 3) {
ctx.addIssue({
code: "too_big",
origin: "array",
maximum: 3,
inclusive: true,
exact: true,
message: "Too many items",
});
}
if (val.length !== new Set(val).size) {
ctx.addIssue({
code: "custom",
message: "No duplicates allowed",
});
}
});
const result = schema.safeParse(["a", "a", "b", "c"]);
if (!result.success) {
console.log(result.error.issues);
// [
// {
// code: "too_big",
// maximum: 3,
// inclusive: true,
// message: "Too many items",
// path: []
// },
// {
// code: "custom",
// message: "No duplicates allowed",
// path: []
// }
// ]
}
Best Practices
Use refine for simple checks
Use .refine() when you just need a true/false validation. It’s simpler and more readable.
Use superRefine for complex validation
Use .superRefine() when you need multiple errors, custom error codes, or fine control over error paths.
Provide helpful error messages
Error messages should clearly explain what’s wrong and how to fix it.
Set appropriate paths
Point errors to the specific field that’s invalid using the path option.
Consider performance
Refinements run after schema validation. Expensive checks (like database calls) should be async and may benefit from caching.
Validate early
Use base schema validation for simple checks. Reserve refinements for complex business logic that requires multiple fields.
Comparison
| Feature | .refine() | .superRefine() |
|---|
| Simplicity | ✅ Simple boolean check | ❌ More verbose |
| Multiple errors | ❌ One error per refine | ✅ Multiple issues per call |
| Error control | Limited | ✅ Full control |
| Type narrowing | ✅ Supports type guards | ❌ No type narrowing |
| Use case | Simple validation | Complex validation |
See Also