Last Updated: 3/12/2026
Refinements
Every Zod schema stores an array of refinements. Refinements are a way to perform custom validation that Zod doesn’t provide a native API for.
Basic Refinements
Use .refine() to add custom validation logic:
import * as z from "zod";
const myString = z.string().refine((val) => val.length <= 255);
myString.parse("hello"); // ✅
myString.parse("a".repeat(300)); // ❌ throwsImportant: Refinement functions should never throw. Instead they should return a falsy value to signal failure. Thrown errors are not caught by Zod.
Custom Error Messages
To customize the error message:
const myString = z.string().refine((val) => val.length > 8, {
error: "Too short!"
});Abort Behavior
By default, validation issues from checks are considered continuable; that is, Zod will execute all checks in sequence, even if one of them causes a validation error.
const myString = z.string()
.refine((val) => val.length > 8, { error: "Too short!" })
.refine((val) => val === val.toLowerCase(), { error: "Must be lowercase" });
const result = myString.safeParse("OH NO");
result.error?.issues;
/* [
{ "code": "custom", "message": "Too short!" },
{ "code": "custom", "message": "Must be lowercase" }
] */To mark a particular refinement as non-continuable, use the abort parameter. Validation will terminate if the check fails:
const myString = z.string()
.refine((val) => val.length > 8, { error: "Too short!", abort: true })
.refine((val) => val === val.toLowerCase(), { error: "Must be lowercase" });
const result = myString.safeParse("OH NO");
result.error?.issues;
// => [{ "code": "custom", "message": "Too short!" }]
// The lowercase check never ranError Paths
To customize the error path, use the path parameter. This is typically only useful in the context of object schemas:
const passwordForm = z
.object({
password: z.string(),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"], // path of error
});
const result = passwordForm.safeParse({ password: "asdf", confirm: "qwer" });
result.error?.issues;
/* [{
"code": "custom",
"path": [ "confirm" ],
"message": "Passwords don't match"
}] */Async Refinements
To define an asynchronous refinement, just pass an async function:
const userId = z.string().refine(async (id) => {
// verify that ID exists in database
const user = await db.users.findUnique({ where: { id } });
return user !== null;
}, {
error: "User not found"
});Important: If you use async refinements, you must use the .parseAsync() method to parse data! Otherwise Zod will throw an error.
const result = await userId.parseAsync("abc123");Conditional Execution with when
By default, refinements don’t run if any non-continuable issues have already been encountered. In some cases, you want finer control over when refinements run.
For instance, consider this “password confirm” check:
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
anotherField: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
schema.parse({
password: "asdf",
confirmPassword: "asdf",
anotherField: 1234 // ❌ this error will prevent the password check from running
});To control when a refinement will run, use the when parameter:
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
anotherField: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
// run if password & confirmPassword are valid
when(payload) {
return schema
.pick({ password: true, confirmPassword: true })
.safeParse(payload.value).success;
},
});Super Refine
The regular .refine API only generates a single issue with a "custom" error code, but .superRefine() makes it possible to create multiple issues using any of Zod’s internal issue types:
const UniqueStringArray = z.array(z.string()).superRefine((val, ctx) => {
if (val.length > 3) {
ctx.addIssue({
code: "too_big",
maximum: 3,
origin: "array",
inclusive: true,
message: "Too many items 😡",
input: val,
});
}
if (val.length !== new Set(val).size) {
ctx.addIssue({
code: "custom",
message: "No duplicates allowed.",
input: val,
});
}
});Common Use Cases
Password Strength
const strongPassword = z.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), "Must contain uppercase letter")
.refine((val) => /[a-z]/.test(val), "Must contain lowercase letter")
.refine((val) => /[0-9]/.test(val), "Must contain number");Unique Array Values
const uniqueArray = z.array(z.string())
.refine((arr) => new Set(arr).size === arr.length, {
error: "Array must contain unique values"
});Date Range Validation
const dateRange = z.object({
startDate: z.date(),
endDate: z.date()
}).refine((data) => data.endDate > data.startDate, {
message: "End date must be after start date",
path: ["endDate"]
});Database Uniqueness Check
const uniqueEmail = z.string().email()
.refine(async (email) => {
const existing = await db.users.findUnique({ where: { email } });
return !existing;
}, {
error: "Email already in use"
});Best Practices
- Never throw inside refinement functions
- Return falsy values to signal validation failure
- Use
abort: truefor critical checks that should stop further validation - Use
pathto target specific fields in object schemas - Use
.superRefine()when you need multiple error messages - Use
.parseAsync()with async refinements - Use
whenfor conditional refinement execution