Skip to Content
advanced-8y4qeprRefinements

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)); // ❌ throws

Important: 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 ran

Error 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

  1. Never throw inside refinement functions
  2. Return falsy values to signal validation failure
  3. Use abort: true for critical checks that should stop further validation
  4. Use path to target specific fields in object schemas
  5. Use .superRefine() when you need multiple error messages
  6. Use .parseAsync() with async refinements
  7. Use when for conditional refinement execution