Handle Zod validation errors with unified error API, custom messages, error formatting, and user-friendly display
Handles Zod v4 validation errors with unified error API, custom messages, and flattened formatting for forms. Use when parsing data fails to display user-friendly error messages.
/plugin marketplace add djankies/claude-configs/plugin install zod-4@claude-configsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/framework-integration.mdComprehensive guide to error handling in Zod v4, covering the unified error API, custom error messages, error formatting, and integration with UI frameworks.
Zod v4 unified all error customization under a single error parameter, replacing multiple deprecated parameters.
v3 (deprecated):
z.string({ message: "Required" });
z.string({ invalid_type_error: "Must be string" });
z.string({ required_error: "Field required" });
z.object({}, { errorMap: customMap });
v4 (unified):
z.string({ error: "Required" });
z.string({ error: "Must be string" });
z.string({ error: "Field required" });
z.object({}, { error: customMap });
String messages:
z.string({ error: "This field is required" });
z.email({ error: "Please enter a valid email address" });
z.number({ error: "Must be a number" }).min(0, {
error: "Must be positive"
});
Error map functions:
const customErrorMap: ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: `Expected ${issue.expected}, got ${issue.received}` };
}
return { message: ctx.defaultError };
};
z.string({ error: customErrorMap });
Always use safeParse instead of parse wrapped in try/catch:
Anti-pattern:
try {
const data = schema.parse(input);
return data;
} catch (error) {
console.error(error);
return null;
}
Best practice:
const result = schema.safeParse(input);
if (!result.success) {
console.error(result.error);
return null;
}
return result.data;
Benefits:
const result = schema.safeParse(input);
if (result.success) {
const data: z.infer<typeof schema> = result.data;
} else {
const error: z.ZodError = result.error;
}
Convert nested error structure to flat format:
const userSchema = z.object({
email: z.email(),
age: z.number().min(18)
});
const result = userSchema.safeParse({
email: 'invalid',
age: 10
});
if (!result.success) {
const flattened = result.error.flatten();
console.log(flattened.fieldErrors);
}
Output:
{
email: ["Invalid email"],
age: ["Number must be greater than or equal to 18"]
}
React Hook Form integration:
const result = formSchema.safeParse(data);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
return {
errors: {
email: errors.email?.[0],
password: errors.password?.[0]
}
};
}
const result = schema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
};
}
return {
success: true,
data: result.data
};
const userSchema = z.object({
email: z.email({
error: "Please enter a valid email address"
}),
password: z.string({
error: "Password is required"
}).min(8, {
error: "Password must be at least 8 characters"
}),
age: z.number({
error: "Age must be a number"
}).min(18, {
error: "You must be 18 or older"
})
});
const passwordSchema = z.string().refine(
(password) => /[A-Z]/.test(password),
{ error: "Password must contain at least one uppercase letter" }
).refine(
(password) => /[0-9]/.test(password),
{ error: "Password must contain at least one number" }
);
const rangeSchema = (min: number, max: number) =>
z.number().refine(
(val) => val >= min && val <= max,
{ error: `Value must be between ${min} and ${max}` }
);
import { z } from 'zod';
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: "This field must be text" };
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') {
return { message: `Minimum ${issue.minimum} characters required` };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
const userSchema = z.object({
email: z.email(),
age: z.number()
}, {
error: (issue, ctx) => {
if (issue.path[0] === 'email') {
return { message: "Email address is invalid" };
}
return { message: ctx.defaultError };
}
});
const errorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
return { message: `Expected ${issue.expected}, got ${issue.received}` };
case z.ZodIssueCode.invalid_string:
return { message: "Invalid format" };
case z.ZodIssueCode.too_small:
return { message: `Minimum ${issue.minimum} required` };
case z.ZodIssueCode.too_big:
return { message: `Maximum ${issue.maximum} allowed` };
case z.ZodIssueCode.invalid_enum_value:
return { message: `Must be one of: ${issue.options.join(', ')}` };
case z.ZodIssueCode.custom:
return { message: issue.message ?? "Invalid value" };
default:
return { message: ctx.defaultError };
}
};
Zod v4 error precedence order (highest to lowest):
z.setErrorMap())z.setErrorMap(globalMap);
const schema = z.string({ error: schemaLevelMap });
const result = schema.safeParse(data, { errorMap: parseLevelMap });
import { z } from 'zod';
const result = schema.safeParse(data);
if (!result.success) {
console.error(z.prettifyError(result.error));
}
const result = schema.safeParse(data);
if (!result.success) {
result.error.issues.forEach(issue => {
console.log(`Field: ${issue.path.join('.')}`);
console.log(`Error: ${issue.message}`);
console.log(`Code: ${issue.code}`);
});
}
Zod integrates seamlessly with popular frameworks for error handling.
Quick patterns:
safeParse + flatten().fieldErrorszodResolver for automatic integrationFor framework-specific examples, see the integrating-zod-frameworks skill from the zod-4 plugin.
const result = schema.safeParse(data); // ✅
try { schema.parse(data) } // ❌
z.email({ error: "Please enter a valid email address" }) // ✅
z.email() // ❌ Generic error
const errors = result.error.flatten().fieldErrors; // ✅
const errors = result.error.issues; // ❌ Complex structure
z.setErrorMap(customMap); // ✅ Consistent across app
it('shows error for invalid email', () => {
const result = schema.safeParse({ email: 'invalid' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe("Invalid email");
}
});
Before:
z.string({ message: "Required" });
z.string({ invalid_type_error: "Must be string" });
z.string({ required_error: "Required" });
After:
z.string({ error: "Required" });
z.string({ error: "Must be string" });
z.string({ error: "Required" });
Before:
z.object({}, { errorMap: customMap });
After:
z.object({}, { error: customMap });
const addressSchema = z.object({
street: z.string({ error: "Street required" }),
city: z.string({ error: "City required" }),
zip: z.string({ error: "ZIP code required" })
});
const userSchema = z.object({
name: z.string(),
address: addressSchema
});
const result = userSchema.safeParse(data);
if (!result.success) {
const errors = result.error.flatten();
}
const schema = z.array(
z.object({
name: z.string({ error: "Name required" })
})
);
const result = schema.safeParse(data);
if (!result.success) {
result.error.issues.forEach(issue => {
console.log(`Index ${issue.path[0]}: ${issue.message}`);
});
}
const schema = z.union([
z.string(),
z.number()
], {
error: "Must be either string or number"
});
Cross-Plugin References:
error parameter