Zod 05 🛡️ Handling Errors — ZodError, issues, and .safeParse()
Validation fails. The question is what happens next. Zod’s error handling is built around a single principle: a validation failure is a structured value, not a string. The .parse() method throws a ZodError containing an issues array; .safeParse() returns that same ZodError inside a discriminated union without throwing . Every issue carries a path that identifies the exact field that failed, a code that categorizes the failure, and a message intended for display or translation.
This chapter covers the error path from failure to handling: the shape of ZodError, how to read issues at flat and nested levels, the formatting utilities that convert issues into form-friendly structures, and the decision between throwing and returning based on whether invalid input is a bug or an expected outcome. The sections that follow walk through the issues array, the formatting functions, and the error-handling patterns that keep failures actionable instead of opaque.
Key point: ZodError.issues is the source of truth for every failure—its path maps errors to fields, its code categorizes the problem, and its message is what the user sees; formatting utilities exist to reshape this array, not to replace it.
Why structured errors matter
The string problem. A validation library that returns "Validation failed" forces every caller to parse prose to find which field was wrong. Zod returns an array of objects, each with a machine-readable path and a human-readable message. A form resolver maps path to a field name; an API handler returns the array as JSON; a test asserts on code rather than on English text that might change with the next release .
The nesting problem. Real data is nested. A failure on user.profile.email is not the same as a failure on email, even though both are strings. The path array encodes the full location: ["user", "profile", "email"]. Flattening the error into a single-level object loses this information; z.treeifyError() preserves it by returning a tree that mirrors the schema’s shape .
The throwing/returning problem. .parse() throws, which is correct when invalid data indicates a programming error: a config file that should have been validated at startup, an internal contract violation, a test fixture. .safeParse() returns a result object, which is correct when invalid data is routine: user input, API request bodies, third-party responses . The error shape is identical; the control flow is the decision.
a. Reading ZodError.issues
Every issue has at least three properties. The path is an array because data is nested; [] means the error is at the root. The code is a string that categorizes the failure (invalid_type, too_small, invalid_format, unrecognized_keys). The message is the display text. Additional properties depend on the code: a too_small issue includes minimum, an invalid_type issue includes expected and received .
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
});
const result = UserSchema.safeParse({ name: "", age: -5 });
if (!result.success) {
for (const issue of result.error.issues) {
console.log(issue.path); // ["name"] or ["age"]
console.log(issue.code); // "too_small"
console.log(issue.message); // "String must contain at least 1 character(s)"
}
}
For flat schemas, z.flattenError() converts the issues array into { formErrors, fieldErrors }. formErrors contains root-level failures (path []), and fieldErrors is an object keyed by field name, each value an array of messages. This is the shape most form libraries expect .
const flattened = z.flattenError(result.error);
// {
// formErrors: [],
// fieldErrors: {
// name: ["String must contain at least 1 character(s)"],
// age: ["Number must be greater than 0"],
// },
// }
b. Nested errors and z.treeifyError()
When the schema nests, flattening loses the structure. z.treeifyError() returns an object that mirrors the schema: each node has an errors array for failures at that level, a properties object for child fields, and an items array for array elements . Accessing nested nodes requires optional chaining because not every branch will have an error.
const NestedSchema = z.object({
user: z.object({
profile: z.object({
email: z.string().email(),
}),
}),
});
const result = NestedSchema.safeParse({
user: { profile: { email: "not-an-email" } },
});
if (!result.success) {
const tree = z.treeifyError(result.error);
// Navigate with optional chaining
console.log(tree.properties?.user?.properties?.profile?.properties?.email?.errors);
// ["Invalid email"]
}
z.prettifyError() produces a human-readable string with bullets and paths, intended for logging and debugging rather than for programmatic consumption . The three formatters serve different consumers: flattenError for flat forms, treeifyError for nested data and nested form fields, prettifyError for console output and error monitoring.
c. Custom messages with the error parameter
Zod 4 unifies error customization under a single error parameter, replacing v3’s message, invalid_type_error, required_error, and errorMap . The parameter accepts a string or a function that receives the issue context, enabling messages that depend on the failure type or the input value.
const EmailSchema = z.email({
error: (issue) =>
issue.input === undefined
? "Email is required."
: "Enter a valid email address.",
});
const AgeSchema = z.number().min(18, {
error: "You must be 18 or older.",
});
Schema-level error customization has the highest precedence. The error function receives issue.code, issue.input, and other properties specific to the issue type, so a single schema can produce different messages for missing input versus invalid format .
Complete Example Session
// ============================================
// PART 1: THE SCHEMA
// ============================================
import { z } from "zod";
const SignupSchema = z.object({
email: z.email({ error: "Enter a valid email address." }),
password: z.string().min(8, { error: "Password must be at least 8 characters." }),
profile: z.object({
name: z.string().min(1, { error: "Name is required." }),
}),
});
// ============================================
// PART 2: SAFE PARSE RETURNS A UNION
// ============================================
const result = SignupSchema.safeParse({
email: "bad",
password: "short",
profile: { name: "" },
});
// ============================================
// PART 3: READING ISSUES DIRECTLY
// ============================================
if (!result.success) {
for (const issue of result.error.issues) {
console.log(issue.path.join("."), issue.code, issue.message);
}
// "email" invalid_format "Enter a valid email address."
// "password" too_small "Password must be at least 8 characters."
// "profile.name" too_small "Name is required."
}
// ============================================
// PART 4: FLATTENING FOR A FORM
// ============================================
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
// fieldErrors.email[0] → "Enter a valid email address."
// fieldErrors.password[0] → "Password must be at least 8 characters."
// Nested "profile.name" does NOT flatten cleanly; it appears as a key
// with the dot path only if the schema is flat.
}
// ============================================
// PART 5: TREEIFYING FOR NESTED DATA
// ============================================
if (!result.success) {
const tree = z.treeifyError(result.error);
const emailError = tree.properties?.email?.errors?.[0];
const nameError = tree.properties?.profile?.properties?.name?.errors?.[0];
}
// ============================================
// PART 6: PRETTIFYING FOR LOGS
// ============================================
if (!result.success) {
console.log(z.prettifyError(result.error));
// ✖ Enter a valid email address.
// → at email
// ✖ Password must be at least 8 characters.
// → at password
// ✖ Name is required.
// → at profile.name
}
// ============================================
// PART 7: THE THROWING VARIANT
// ============================================
try {
SignupSchema.parse({ email: "bad", password: "short", profile: { name: "" } });
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.issues.length); // 3
}
}
The parts covered the schema with custom messages, the safe parse union, direct issue reading, flattening for flat forms, treeifying for nested access, prettifying for logs, and the throwing variant with instanceof narrowing.
Quick Reference
ZodError Structure
| Field | Type | Meaning |
|---|---|---|
issues | ZodIssue[] | All validation failures |
issues[i].path | (string | number)[] | Nested field location |
issues[i].code | string | Failure category |
issues[i].message | string | Display text |
Error Formatters (Zod 4)
| Function | Output | Best For |
|---|---|---|
z.flattenError(err) | { formErrors, fieldErrors } | Flat forms |
z.treeifyError(err) | Nested tree mirroring schema | Nested data, nested fields |
z.prettifyError(err) | Human-readable string | Logging, debugging |
Parse Method Selection
| Method | On Failure | Use When |
|---|---|---|
.parse() | Throws ZodError | Invalid input is a bug |
.safeParse() | Returns { success: false, error } | Invalid input is expected |
Best Practices
✅ Do This:
// Use safeParse for user input
const result = Schema.safeParse(req.body); // ✅
// Narrow before accessing
if (!result.success) { result.error.issues; } // ✅
// Use path to map errors to fields
issue.path.join("."); // ✅
// Use flattenError for flat forms
const { fieldErrors } = z.flattenError(result.error); // ✅
// Use treeifyError for nested access
tree.properties?.user?.errors; // ✅
// Custom messages on the schema
z.email({ error: "Enter a valid email." }); // ✅
❌ Don’t Do This:
// Uncaught parse in a handler
const data = Schema.parse(req.body); // ❌ (500 error)
// Parse the message string for field names
result.error.message.includes("email"); // ❌
// Assume flattenError works on nested paths
// nested errors do not flatten to dot keys // ❌
// Use deprecated instance methods (v4)
result.error.format(); // ❌ (use z.treeifyError)
// Access tree without optional chaining
tree.properties.user.errors; // ❌ (may throw)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| 500 on invalid input | .parse() in handler | .safeParse() and return 400 |
| Field errors not mapped | Reading error.message | Iterate issues and use path |
| Nested errors lost | flattenError on nested schema | Use treeifyError |
| Tree access throws | Missing optional chaining | tree.properties?.user?.errors |
| Deprecated methods fail | error.format() in Zod 4 | z.treeifyError(error) |
Real-World Examples
1. Request body validation with structured response
const result = Schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ issues: result.error.issues });
}
2. Form field errors
const { fieldErrors } = z.flattenError(result.error);
const emailError = fieldErrors.email?.[0];
3. Nested field access
const tree = z.treeifyError(result.error);
const nameError = tree.properties?.profile?.properties?.name?.errors?.[0];
4. Logging with paths
console.log(z.prettifyError(result.error));
5. Custom message by issue code
z.string({
error: (iss) => iss.input === undefined ? "Required." : "Must be a string.",
});
6. Try/catch for internal boundaries
try { ConfigSchema.parse(env); } catch (e) { if (e instanceof z.ZodError) { ... } }
7. Root-level errors
const { formErrors } = z.flattenError(result.error);
// formErrors contains issues with path: []
8. Array item errors
tree.properties?.items?.items?.[0]?.errors?.[0];
9. Instanceof narrowing
if (err instanceof z.ZodError) { err.issues; }
10. Success branch typing
if (result.success) { const user: User = result.data; }
Visual
ZodError Structure
┌─────────────────────────────────────────────────────────┐
│ ZodError │
│ │ │
│ └── issues: [ │
│ { │
│ path: ["profile", "email"], │
│ code: "invalid_format", │
│ message: "Enter a valid email address." │
│ }, │
│ { │
│ path: ["password"], │
│ code: "too_small", │
│ message: "Must be at least 8 characters." │
│ } │
│ ] │
│ │
│ path → field location │
│ code → failure category │
│ message → display text │
└─────────────────────────────────────────────────────────┘
Flatten vs Treeify
┌─────────────────────────────────────────────────────────┐
│ FLATTEN (flat schemas) │
│ │
│ { formErrors: [], │
│ fieldErrors: { │
│ email: ["Invalid email"], │
│ password: ["Too short"] │
│ } } │
│ │
│ One level. Fast. Form-friendly. │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ TREEIFY (nested schemas) │
│ │
│ { errors: [], │
│ properties: { │
│ profile: { │
│ errors: [], │
│ properties: { │
│ email: { errors: ["Invalid email"] } │
│ } │
│ } │
│ } } │
│ │
│ Mirrors schema shape. Optional chaining required. │
└─────────────────────────────────────────────────────────┘
Parse Selection Decision
┌─────────────────────────────────────────────────────────┐
│ THROW OR RETURN? │
│ │
│ Is the input from an untrusted source? │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ safeParse parse │
│ (return (throw, │
│ result) catch or │
│ let crash) │
│ │
│ User input → safeParse → 400 │
│ Config → parse → crash early │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Error class | ZodError (subclass of Error) |
| Issue location | error.issues[i].path (array) |
| Issue category | error.issues[i].code |
| Display text | error.issues[i].message |
| Flat formatter | z.flattenError(err) → { formErrors, fieldErrors } |
| Nested formatter | z.treeifyError(err) → tree with properties/items |
| Log formatter | z.prettifyError(err) → string |
| Safe parse result | { success: true, data } | { success: false, error } |
| Deprecated (v4) | error.format(), error.flatten() |
Key takeaways:
issuesis the data; everything else is a view. Thepath,code, andmessageon each issue are the source of truth. Formatters reshape this array for specific consumers ..safeParse()returns the sameZodErrorthat.parse()throws. The difference is control flow, not error shape. UsesafeParsefor user input so a 400 is possible instead of a 500 .flattenErroris for flat schemas. It produces{ formErrors, fieldErrors }, which most form libraries expect. Nested errors do not flatten cleanly; usetreeifyErrorinstead .treeifyErrormirrors the schema. Navigate with optional chaining because not every branch has an error. Array items useitems, object properties useproperties.- Zod 4 deprecated the instance methods.
error.format()anderror.flatten()are replaced byz.treeifyError(error)andz.flattenError(error). - Custom messages go on the schema. The
errorparameter accepts a string or a function that receives the issue context, producing different messages for missing versus invalid input .
Remember: A validation failure is not a dead end; it is a structured message about what went wrong and where. ZodError.issues carries that message with paths that map to fields and codes that categorize the failure. The formatters exist to deliver the message to its consumer: flattenError for the form, treeifyError for nested data, prettifyError for the log. The only decision left to the developer is whether the failure should throw or return, and that depends on whether the input was trusted. Untrusted input gets safeParse; everything else gets parse.
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!