| | |

Zod 05 🛡️ Handling Errors — ZodError, issues, and .safeParse()

⏱️ Estimated read: 10 min | |

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

FieldTypeMeaning
issuesZodIssue[]All validation failures
issues[i].path(string | number)[]Nested field location
issues[i].codestringFailure category
issues[i].messagestringDisplay text

Error Formatters (Zod 4)

FunctionOutputBest For
z.flattenError(err){ formErrors, fieldErrors }Flat forms
z.treeifyError(err)Nested tree mirroring schemaNested data, nested fields
z.prettifyError(err)Human-readable stringLogging, debugging

Parse Method Selection

MethodOn FailureUse When
.parse()Throws ZodErrorInvalid 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

PitfallWhy It HappensFix
500 on invalid input.parse() in handler.safeParse() and return 400
Field errors not mappedReading error.messageIterate issues and use path
Nested errors lostflattenError on nested schemaUse treeifyError
Tree access throwsMissing optional chainingtree.properties?.user?.errors
Deprecated methods failerror.format() in Zod 4z.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

ItemValue
Error classZodError (subclass of Error)
Issue locationerror.issues[i].path (array)
Issue categoryerror.issues[i].code
Display texterror.issues[i].message
Flat formatterz.flattenError(err) → { formErrors, fieldErrors }
Nested formatterz.treeifyError(err) → tree with properties/items
Log formatterz.prettifyError(err) → string
Safe parse result{ success: true, data } | { success: false, error }
Deprecated (v4)error.format(), error.flatten()

Key takeaways:

  • issues is the data; everything else is a view. The path, code, and message on each issue are the source of truth. Formatters reshape this array for specific consumers .
  • .safeParse() returns the same ZodError that .parse() throws. The difference is control flow, not error shape. Use safeParse for user input so a 400 is possible instead of a 500 .
  • flattenError is for flat schemas. It produces { formErrors, fieldErrors }, which most form libraries expect. Nested errors do not flatten cleanly; use treeifyError instead .
  • treeifyError mirrors the schema. Navigate with optional chaining because not every branch has an error. Array items use items, object properties use properties .
  • Zod 4 deprecated the instance methods. error.format() and error.flatten() are replaced by z.treeifyError(error) and z.flattenError(error) .
  • Custom messages go on the schema. The error parameter 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.

Was this helpful?


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!