Overview
Refinements allow you to add custom validation logic beyond Zod’s built-in validators. While transformations modify data, refinements validate data without changing it.
Basic Refinement with .refine()
Use .refine() to add custom validation:
import { z } from 'zod';
const schema = z.string().refine(
(val) => val.length <= 10,
'String must be 10 characters or less'
);
schema.parse('hello'); // OK
schema.parse('hello world'); // Error: String must be 10 characters or less
From packages/zod/src/v4/classic/schemas.ts:92-95, the .refine() signature:
refine<Ch extends (arg: Output) => unknown | Promise<unknown>>(
check: Ch,
params?: string | $ZodCustomParams
): Ch extends (arg: any) => arg is infer R ? this & ZodType<R, Input> : this
Custom Error Messages
Provide custom error messages:
// String message
const schema1 = z.string().refine(
(val) => val.includes('@'),
'Must contain @ symbol'
);
// Object with message
const schema2 = z.string().refine(
(val) => val.includes('@'),
{ message: 'Must contain @ symbol' }
);
// Dynamic message
const schema3 = z.number().refine(
(val) => val >= 0,
(val) => ({ message: `Expected non-negative, got ${val}` })
);
From packages/zod/src/v4/classic/tests/refine.test.ts:54-75:
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: '[email protected]',
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'
);
}
Refine on Objects
Refinements are powerful for cross-field validation:
const PasswordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'], // Error will point to this field
}
);
From packages/zod/src/v4/classic/tests/refine.test.ts:169-181:
const result = 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']);
}
Error Path
The path option specifies where the error should appear:
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'], // Error appears on 'end' field
}
);
Async Refinements
Refinements can be asynchronous for database checks, API calls, etc:
const schema = z.string().refine(
async (email) => {
// Check if email exists in database
const exists = await checkEmailExists(email);
return !exists;
},
{ message: 'Email already exists' }
);
// Must use parseAsync for async refinements
const result = await schema.parseAsync('[email protected]');
From packages/zod/src/v4/classic/tests/refine.test.ts:77-108:
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: '[email protected]',
password: 'password',
confirmPassword: 'password',
};
await expect(validationSchema.parseAsync(validData)).resolves.toEqual(validData);
// Should fail with non-matching passwords
await expect(
validationSchema.parseAsync({
email: '[email protected]',
password: 'password',
confirmPassword: 'different',
})
).rejects.toThrow();
Async refinements require parseAsync() or safeParseAsync(). Using parse() will throw an error.
superRefine() for Advanced Validation
For complex validation with multiple errors, use .superRefine():
const schema = z.array(z.string()).superRefine((val, ctx) => {
// Check length
if (val.length > 3) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 3,
inclusive: true,
type: 'array',
message: 'Too many items',
});
}
// Check for duplicates
if (val.length !== new Set(val).size) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Array contains duplicates',
});
}
});
From packages/zod/src/v4/classic/schemas.ts:96-98, the .superRefine() signature:
superRefine(
refinement: (arg: Output, ctx: $RefinementCtx) => void | Promise<void>
): this
From packages/zod/src/v4/classic/tests/refine.test.ts:183-215:
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.`,
});
}
});
const result = Strings.safeParse(['asdf', 'asdf', 'asdf', 'asdf']);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.length).toBe(2);
expect(result.error.issues[0].message).toBe('Too many items 😡');
expect(result.error.issues[1].message).toBe('No duplicates allowed.');
}
Adding Multiple Issues
.superRefine() allows reporting multiple validation errors:
const PasswordStrength = z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 8,
type: 'string',
inclusive: true,
message: 'Password must be at least 8 characters',
});
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain uppercase letter',
});
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain a number',
});
}
});
Early Termination
Control validation flow with early termination options:
Using fatal: true
const schema = z.string().superRefine((val, ctx) => {
if (val.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
fatal: true, // Stop validation immediately
message: 'Too short',
});
}
}).refine(() => false); // This won't run if fatal issue occurs
From packages/zod/src/v4/classic/tests/refine.test.ts:133-154:
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');
}
Using continue: false
const schema = z.string().superRefine((val, ctx) => {
if (val.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
continue: false, // Stop this refinement, but continue others
message: 'BAD',
});
}
});
Using abort in .refine()
const schema = z.string()
.refine(() => false, { abort: true }) // Abort on failure
.refine(() => false); // This won't run
From packages/zod/src/v4/classic/tests/refine.test.ts:155-167:
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);
}
Type Narrowing with Refinements
Refinements can narrow TypeScript types using type predicates:
const isValidEmail = (val: string): val is `${string}@${string}` => {
return val.includes('@');
};
const schema = z.string().refine(isValidEmail);
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // `${string}@${string}`
From packages/zod/src/v4/classic/tests/refine.test.ts:422-432:
// Type predicate narrows the type
const schema = z.string().refine((val): val is 'a' => val === 'a');
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // 'a'
// Without type predicate, type doesn't narrow
const schema2 = z.string().refine((val) => val === 'a');
type Input2 = z.input<typeof schema2>; // string
type Output2 = z.output<typeof schema2>; // string (not narrowed)
Practical Examples
Email Uniqueness Check
const UniqueEmail = z.string()
.email()
.refine(
async (email) => {
const user = await db.users.findOne({ email });
return !user;
},
{ message: 'Email already exists' }
);
Password Confirmation
const RegistrationSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'],
}
);
Date Range Validation
const EventSchema = z.object({
title: z.string(),
startDate: z.date(),
endDate: z.date(),
}).refine(
(data) => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate'],
}
);
Complex Business Logic
const OrderSchema = z.object({
items: z.array(z.object({
id: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
})),
discount: z.number().min(0).max(1),
shippingCost: z.number().nonnegative(),
}).superRefine((data, ctx) => {
const subtotal = data.items.reduce(
(sum, item) => sum + item.quantity * item.price,
0
);
const total = subtotal * (1 - data.discount) + data.shippingCost;
// Minimum order value
if (total < 10) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Order total must be at least $10',
path: ['items'],
});
}
// Free shipping threshold
if (subtotal >= 100 && data.shippingCost > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Orders over $100 qualify for free shipping',
path: ['shippingCost'],
});
}
});
Credit Card Validation
const CreditCardSchema = z.string()
.regex(/^\d{16}$/, 'Must be 16 digits')
.refine(
(num) => {
// Luhn algorithm
let sum = 0;
let isEven = false;
for (let i = num.length - 1; i >= 0; i--) {
let digit = parseInt(num[i], 10);
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
},
{ message: 'Invalid credit card number' }
);
Key Difference:
- Refinements validate data without changing it
- Transformations modify data after validation
// Refinement: validates, doesn't change value
const refined = z.string().refine((val) => val.length > 5);
type RefinedOutput = z.output<typeof refined>; // string
const result1 = refined.parse('hello world'); // 'hello world'
// Transformation: changes the value
const transformed = z.string().transform((val) => val.length);
type TransformedOutput = z.output<typeof transformed>; // number
const result2 = transformed.parse('hello world'); // 11
Chaining Refinements
Multiple refinements can be chained:
const schema = z.number()
.refine((n) => n >= 0, 'Must be non-negative')
.refine((n) => n <= 100, 'Must be at most 100')
.refine((n) => n % 5 === 0, 'Must be multiple of 5');
Async refinements can be slow. Consider caching or batching database lookups for better performance.
// Slow: Database call for each value
const slow = z.string().refine(async (val) => {
return await db.check(val);
});
// Better: Batch validation
const better = z.array(z.string()).refine(async (values) => {
const results = await db.batchCheck(values);
return results.every((r) => r.valid);
});
Best Practices
- Use built-in validators first - They’re optimized and well-tested
- Keep refinements focused - One validation per refinement
- Use .superRefine() for multiple errors - Better UX than stopping at first error
- Set appropriate error paths - Help users fix the right field
- Consider async performance - Cache or batch when possible
- Use type predicates for narrowing - Get better TypeScript types
// Good: Focused, clear refinements
const schema = z.object({
email: z.string().email(),
age: z.number().positive(),
}).refine(
(data) => data.age >= 18,
{ message: 'Must be 18 or older', path: ['age'] }
);
// Bad: Everything in one refinement
const bad = z.object({
email: z.string(),
age: z.number(),
}).refine((data) => {
return data.email.includes('@') && data.age >= 18;
}, 'Invalid data'); // Unclear what's wrong
Common Patterns
Conditional Validation
const schema = z.object({
country: z.string(),
zipCode: z.string(),
}).refine(
(data) => {
if (data.country === 'US') {
return /^\d{5}(-\d{4})?$/.test(data.zipCode);
}
return true; // Skip validation for other countries
},
{
message: 'Invalid US zip code',
path: ['zipCode'],
}
);
Dependent Fields
const schema = z.object({
hasAccount: z.boolean(),
accountId: z.string().optional(),
}).refine(
(data) => {
if (data.hasAccount) {
return data.accountId !== undefined;
}
return true;
},
{
message: 'Account ID required when "has account" is checked',
path: ['accountId'],
}
);
Next Steps