Zod 16 🛡️ Error Customization — The unified error parameter in v4
Zod v4 replaced the fragmented error options of v3 with a single error parameter. In v3, a schema could accept message, invalid_type_error, required_error, or an errorMap, and each served a different purpose. In v4, all of them are removed, and error is the only parameter that customizes a validation message . The change is a simplification in syntax and a consolidation in behavior: one parameter, two forms (string or function), and a precedence chain that determines which customization wins when multiple are present.
This chapter covers the unified error parameter, the string and function forms, the precedence rules that determine which message appears, and the migration patterns that replace the removed v3 options. The sections that follow walk through the parameter’s forms, the global and per-parse customization layers, and the edge cases where the unified parameter does not reach.
Key point: In Zod v4, error replaces message, invalid_type_error, required_error, and errorMap; it accepts a string or a function that receives the issue context, and schema-level errors take precedence over per-parse and global error maps.
Why the unified parameter exists
The fragmentation problem. Zod v3 had four ways to customize an error message, and they did not compose cleanly. message set a static string; invalid_type_error applied only when the input was the wrong type; required_error applied only when the input was undefined; and errorMap was a function that received the issue and the default error context. A schema that needed different messages for “missing” and “wrong type” had to use both required_error and invalid_type_error, and a schema that needed a dynamic message had to use errorMap . The API grew because each case was added separately.
The precedence problem. The fragmented options interacted in ways that were not always obvious. A per-parse errorMap passed to .parse() could override a schema-level message, which meant the same schema could produce different errors depending on how it was called. Zod v4 changed the precedence order to be more consistent: a per-parse error map no longer takes precedence over a schema-level error map . The unified error parameter makes the schema-level customization the primary mechanism, and the per-parse and global layers become fallbacks for cases the schema does not handle.
The localization problem. The function form of error receives the issue context, which includes the input, the code, and the schema-specific properties like minimum for a too_small issue. This is what makes dynamic messages possible: a single schema can return “This field is required” when the input is undefined and “Must be a string” when the input is a number . The function form is the replacement for errorMap, but it is attached to the schema rather than passed at parse time.
a. The string form and the function form
The error parameter accepts a string for a static message. This is the direct replacement for message, required_error, and invalid_type_error in the simple case.
import { z } from "zod";
// Static message on a schema
const NameSchema = z.string({ error: "Name is required" });
// Static message on a check
const AgeSchema = z.number().min(18, { error: "Must be 18 or older" });
// Shorthand: string as second argument
const ShortSchema = z.number().min(18, "Must be 18 or older");
The function form receives an issue object and returns a string or undefined. Returning undefined falls back to the default message, which is useful when only some codes should be customized .
// Function form: different messages by issue code
const EmailSchema = z.email({
error: (issue) => {
if (issue.input === undefined) return "Email is required.";
return "Enter a valid email address.";
},
});
// Function form: fall back to default for unhandled codes
const PortSchema = z.number({
error: (issue) => {
if (issue.code === "invalid_type") return "Port must be a number.";
return undefined; // use default for other codes
},
});
The issue object’s properties depend on the check that failed. A too_small issue has a minimum property; an invalid_type issue has expected and received; a custom issue has whatever the refinement passed. TypeScript’s autocomplete reveals the available properties for the specific schema .
b. Precedence: schema, per-parse, global, locale
Zod v4 defines a five-level precedence chain for error messages, from highest to lowest priority :
| Priority | Layer | Mechanism |
|---|---|---|
| 1 | Check-level | Error on the individual check (min(18, { error })) |
| 2 | Schema-level | Error on the schema constructor (z.string({ error })) |
| 3 | Per-parse | Error map passed to .parse(data, { error }) |
| 4 | Global | z.config({ customError: ... }) |
| 5 | Locale | z.config({ localeError: ... }) |
The check-level error is the most specific and wins over the schema-level error. A schema constructed with z.number({ error: "Must be a number" }) and a check min(18, { error: "Must be 18+" }) produces the check-level message for a too_small issue and the schema-level message for an invalid_type issue.
The per-parse error map is lower than the schema-level error in v4, which is the change from v3 . A schema that defines its own error cannot have that error overridden by a per-parse map. The per-parse layer is for cases where the schema has no custom error and the caller wants to supply one without modifying the schema.
The global and locale layers are the fallbacks for schemas that define no errors at all. z.config({ customError }) sets a global map that applies to every schema; z.config({ localeError }) sets the locale-specific map that the global map can delegate to. The global map receives the issue and can return a string or undefined to fall through to the locale map .
c. The edge cases the unified parameter does not reach
The unified error parameter is the canonical mechanism, but it does not cover every schema. The z.instanceof() schema is the documented exception: it sets a default schema-level error message, and schema-level errors override global error maps. The result is that z.config({ customError }) does not customize the instanceof error, and locale packages cannot translate it because the schema-level default wins .
// The global customError does not apply here
const TestSchema = z.instanceof(Test);
TestSchema.safeParse("whatever"); // "Input not instance of Test"
// The fix: pass error directly to instanceof
const LocalizedSchema = z.instanceof(Test, { error: "Must be a Test instance" });
The same pattern applies to any schema that hardcodes a default message at the schema level. The fix is to pass the error parameter directly to the schema constructor, not to rely on the global map. For schemas generated in a factory, the factory should accept a translation function and pass the translated message to the constructor .
Complete Example Session
// ============================================
// PART 1: STRING FORM
// ============================================
import { z } from "zod";
const NameSchema = z.string({ error: "Name is required" });
NameSchema.safeParse(undefined);
// issue.message === "Name is required"
// ============================================
// PART 2: FUNCTION FORM
// ============================================
const EmailSchema = z.email({
error: (issue) =>
issue.input === undefined
? "Email is required."
: "Enter a valid email address.",
});
// ============================================
// PART 3: CHECK-LEVEL ERROR
// ============================================
const AgeSchema = z
.number({ error: "Age must be a number" })
.min(18, { error: "Must be 18 or older" });
AgeSchema.safeParse("x"); // "Age must be a number" (schema-level)
AgeSchema.safeParse(12); // "Must be 18 or older" (check-level)
// ============================================
// PART 4: GLOBAL ERROR MAP
// ============================================
z.config({
customError: (issue) => {
if (issue.code === "invalid_type") return "Invalid value.";
return undefined; // fall back to locale/default
},
});
// ============================================
// PART 5: THE instanceof EXCEPTION
// ============================================
class Test {}
const T = z.instanceof(Test, { error: "Must be a Test" }); // explicit
The parts covered the string form, the function form with input-dependent messages, the check-level versus schema-level precedence, the global map, and the instanceof exception.
Quick Reference
The error Parameter
| Form | Syntax | Use |
|---|---|---|
| String | { error: "..." } | Static message |
| Function | { error: (issue) => "..." } | Dynamic message |
| Shorthand | .min(18, "message") | Check-level static |
Removed v3 Options
| v3 Option | v4 Replacement |
|---|---|
message | error |
invalid_type_error | error with function |
required_error | error with function |
errorMap | error with function |
Precedence
| Priority | Layer |
|---|---|
| 1 | Check-level error |
| 2 | Schema-level error |
| 3 | Per-parse error map |
| 4 | Global customError |
| 5 | Locale localeError |
Best Practices
✅ Do This:
// Use string form for static messages
z.string({ error: "Required" }) // ✅
// Use function form for input-dependent messages
z.email({ error: (iss) => iss.input === undefined ? "Required" : "Invalid" }) // ✅
// Pass error directly to instanceof
z.instanceof(Test, { error: "Must be a Test" }) // ✅
// Return undefined to fall back
return undefined; // ✅
❌ Don’t Do This:
// Use v3 message option
z.string({ message: "Required" }) // ignored // ❌
// Use v3 required_error
z.string({ required_error: "Required" }) // removed // ❌
// Rely on global customError for instanceof
z.config({ customError: ... }); z.instanceof(Test) // ❌
// Use errorMap parameter
z.string({ errorMap: fn }) // removed // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Custom message not appearing | Used message instead of error | Replace with error |
instanceof not localized | Schema-level default wins | Pass error to constructor |
| Per-parse overrides schema | v3 behavior, changed in v4 | Schema-level is higher priority |
| Function returns nothing | Forgot return | Return string or undefined |
Real-World Examples
1. Static schema error
z.string({ error: "Name is required" });
2. Dynamic error by input
z.email({ error: (iss) => iss.input === undefined ? "Required" : "Invalid" });
3. Check-level error
z.number().min(18, { error: "Must be 18+" });
4. Shorthand check error
z.number().min(18, "Must be 18+");
5. Fallback to default
error: (iss) => iss.code === "invalid_type" ? "Not a number" : undefined;
6. Global map
z.config({ customError: (iss) => ... });
7. instanceof error
z.instanceof(Test, { error: "Must be a Test" });
8. v3 to v4 replacement
// { message: "..." } → { error: "..." }
9. Per-parse map
schema.parse(data, { error: (iss) => ... });
10. Locale fallback
z.config({ localeError: z.locales.fr().localeError });
Visual
The Unified Parameter
┌─────────────────────────────────────────────────────────┐
│ v3 (REMOVED) v4 (UNIFIED) │
│ │
│ message error: "..." │
│ invalid_type_error error: (iss) => ... │
│ required_error error: (iss) => ... │
│ errorMap error: (iss) => ... │
│ │
│ One parameter, two forms │
└─────────────────────────────────────────────────────────┘
Precedence Chain
┌─────────────────────────────────────────────────────────┐
│ HIGHEST │
│ ┌──────────────────────────────────────────┐ │
│ │ Check-level error │ │
│ │ min(18, { error: "..." }) │ │
│ ├──────────────────────────────────────────┤ │
│ │ Schema-level error │ │
│ │ z.string({ error: "..." }) │ │
│ ├──────────────────────────────────────────┤ │
│ │ Per-parse error map │ │
│ │ .parse(data, { error: ... }) │ │
│ ├──────────────────────────────────────────┤ │
│ │ Global customError │ │
│ │ z.config({ customError: ... }) │ │
│ ├──────────────────────────────────────────┤ │
│ │ Locale localeError │ │
│ │ z.config({ localeError: ... }) │ │
│ └──────────────────────────────────────────┘ │
│ LOWEST │
└─────────────────────────────────────────────────────────┘
Function Form Decision
┌─────────────────────────────────────────────────────────┐
│ error: (issue) => { │
│ if (issue.input === undefined) return "Required"; │
│ if (issue.code === "invalid_type") return "Wrong"; │
│ return undefined; // default │
│ } │
│ │
│ issue.input → the raw value │
│ issue.code → "invalid_type", "too_small", etc. │
│ undefined → fall back to next layer │
└─────────────────────────────────────────────────────────┘
The instanceof Exception
┌─────────────────────────────────────────────────────────┐
│ z.instanceof(Test) │
│ │ │
│ ▼ │
│ Schema-level default: "Input not instance of Test" │
│ │ │
│ ▼ │
│ Global customError is LOWER priority │
│ → global map does not apply │
│ │
│ Fix: z.instanceof(Test, { error: "..." }) │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Parameter | error |
| Forms | String, function |
| Replaces | message, invalid_type_error, required_error, errorMap |
| Function receives | Issue context (input, code, etc.) |
Return undefined | Fall back to next layer |
| Highest priority | Check-level error |
| Lowest priority | Locale error map |
| Exception | z.instanceof needs explicit error |
Key takeaways:
erroris the only customization parameter in v4. The v3 options (message,invalid_type_error,required_error,errorMap) are removed, and code that still uses them will not produce the intended messages .- The function form replaces
errorMapand the type-specific errors. It receives the issue, can branch oninputandcode, and can returnundefinedto fall through to the default . - Precedence is layered. Check-level errors win over schema-level errors, which win over per-parse maps, which win over global and locale maps. The per-parse layer moved below schema-level in v4 .
z.instanceofis the documented exception. It hardcodes a schema-level error, which overrides the global map. Pass the error directly to the constructor to customize or localize it .- Migration is a search-and-replace.
{ message: "..." }becomes{ error: "..." };required_errorandinvalid_type_errorbecome function-formerror;errorMapbecomes function-formerror.
Remember: In Zod v4, an error message is a property of the schema, not a parameter of the parse call. The error parameter accepts a string for the common case and a function for the cases where the message depends on what went wrong. The function receives the issue, which carries the input and the code, and returning undefined hands control to the next layer in the precedence chain. When a custom message does not appear, check the parameter name first: message is v3, error is v4. When a global map does not apply, check the schema: instanceof and other schemas with hardcoded defaults need the error passed directly.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!