3.7 KiB
3.7 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| Add Path to Refinement Errors | MEDIUM | Errors without path show at object level; adding path highlights the specific field that failed | refine, path, errors, forms |
Add Path to Refinement Errors
When using .refine() on object schemas for cross-field validation, add a path option to indicate which field the error relates to. Without it, the error appears at the object level, making form error display confusing.
Incorrect (error at object level):
import { z } from 'zod'
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match' } // No path specified
)
const result = formSchema.safeParse({
password: 'secret123',
confirmPassword: 'different',
})
if (!result.success) {
const flattened = result.error.flatten()
// {
// formErrors: ['Passwords do not match'], // At form level!
// fieldErrors: {} // Empty - no field association
// }
}
// Form UI can't highlight which field has the error
Correct (error with path):
import { z } from 'zod'
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'], // Error appears on this field
}
)
const result = formSchema.safeParse({
password: 'secret123',
confirmPassword: 'different',
})
if (!result.success) {
const flattened = result.error.flatten()
// {
// formErrors: [],
// fieldErrors: {
// confirmPassword: ['Passwords do not match'] // Associated with field
// }
// }
}
// Form can now show error next to confirmPassword input
Multiple cross-field validations:
const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minDays: z.number().optional(),
maxDays: z.number().optional(),
}).refine(
(data) => data.endDate >= data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
).refine(
(data) => {
if (!data.minDays) return true
const days = (data.endDate.getTime() - data.startDate.getTime()) / 86400000
return days >= data.minDays
},
{ message: 'Date range is too short', path: ['endDate'] }
).refine(
(data) => {
if (!data.maxDays) return true
const days = (data.endDate.getTime() - data.startDate.getTime()) / 86400000
return days <= data.maxDays
},
{ message: 'Date range is too long', path: ['endDate'] }
)
With superRefine for multiple path errors:
const orderSchema = z.object({
billingAddress: z.object({
street: z.string(),
city: z.string(),
}),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
}),
sameAsBilling: z.boolean(),
}).superRefine((data, ctx) => {
if (data.sameAsBilling) {
// If sameAsBilling but addresses differ, show errors on shipping
if (data.shippingAddress.street !== data.billingAddress.street) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Must match billing address',
path: ['shippingAddress', 'street'], // Nested path
})
}
if (data.shippingAddress.city !== data.billingAddress.city) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Must match billing address',
path: ['shippingAddress', 'city'],
})
}
}
})
When NOT to use this pattern:
- When the error genuinely applies to the whole object
- Simple single-field refinements (path is implicit)
Reference: Zod API - refine