hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

Error formatting

Making errors useful

Validation catches bad data. But catching it is only half the job. The other half is telling the client what went wrong in a way they can actually use. A vague “Invalid input” message is barely better than a 500 error. A field-level error map that a form UI can display next to each input? That is useful.

This lesson is about turning Zod’s error output into clean API responses.

The ZodError object

When validation fails, Zod produces a ZodError containing detailed information about every problem it found:

import { z } from "zod/v4";

const schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive(),
});

const result = schema.safeParse({ name: "", email: "bad", age: -1 });
if (!result.success) {
  console.log(result.error.issues);
}

result.error.issues is an array of issue objects, one per validation failure:

[
  { path: ["name"], message: "String must contain at least 1 character(s)", code: "too_small" },
  { path: ["email"], message: "Invalid email", code: "invalid_string" },
  { path: ["age"], message: "Number must be greater than 0", code: "too_small" },
];

Each issue has three key parts: path tells you which field failed, message is a human-readable description, and code is a machine-readable error type. This is rich information, but the raw issues array is not the most convenient format for API responses.

.flatten(): the format clients want

.flatten() groups errors by field name. This is exactly what form UIs need to display errors next to each input:

const flattened = result.error.flatten();
// {
//   formErrors: [],     // errors not attached to a specific field
//   fieldErrors: {
//     name: ["String must contain at least 1 character(s)"],
//     email: ["Invalid email"],
//     age: ["Number must be greater than 0"],
//   }
// }

Each field maps to an array of error messages. A field can have multiple errors (for example, “too short” and “missing required character” at the same time). formErrors collects any errors that are not attached to a specific field, like object-level refinements without a path.

Building an API error response

Here is a helper function that turns a ZodError into a clean API response:

function formatValidationErrors(error: z.ZodError) {
  const flattened = error.flatten();
  return {
    error: {
      code: "VALIDATION_ERROR",
      message: "Invalid input",
      fields: flattened.fieldErrors,
    },
  };
}

// In a route handler:
if (!result.success) {
  return Response.json(formatValidationErrors(result.error), { status: 400 });
}

The API response looks like this:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "fields": {
      "name": ["String must contain at least 1 character(s)"],
      "email": ["Invalid email"],
      "age": ["Number must be greater than 0"]
    }
  }
}

The client knows exactly which fields have problems and what the problems are. A form UI can loop through fields and display each error next to the corresponding input. This is how production APIs communicate validation failures.

[!NOTE] The Error Handling course’s ValidationError class produces this exact format. Now you know where the field-level errors come from: Zod’s .flatten().

Custom error messages

Zod’s default messages are functional but generic. You can override them with the message option to make them more user-friendly:

const schema = z.object({
  name: z.string({ message: "Name is required" }).min(1, { message: "Name cannot be empty" }),
  email: z.string().email({ message: "Please enter a valid email address" }),
  age: z.number({ message: "Age must be a number" }).positive({ message: "Age must be positive" }),
});

“Please enter a valid email address” is friendlier than “Invalid email.” For user-facing applications, custom messages make a real difference in usability. For internal APIs consumed by other developers, the defaults are usually fine.

Nested error paths

For nested objects and arrays, the error path includes all levels so you can trace exactly where the problem is:

const schema = z.object({
  address: z.object({
    zip: z.string().regex(/^\d{5}$/),
  }),
  tags: z.array(z.string().min(1)),
});

const result = schema.safeParse({
  address: { zip: "abc" },
  tags: ["valid", ""],
});

// Issues:
// { path: ["address", "zip"], message: "Invalid" }
// { path: ["tags", 1], message: "String must contain at least 1 character(s)" }

address.zip failed. tags[1] (the second element, at index 1) failed. The paths guide the client to the exact location, whether it is a nested object field or a specific element in an array.

Now we know how to validate data and how to communicate errors clearly. The last piece of the puzzle is getting TypeScript types from our schemas, so we never have to maintain types separately.

Exercises

Exercise 1: Parse invalid data with safeParse. Log result.error.issues. Examine the path, message, and code of each issue.

Exercise 2: Use .flatten() to group errors by field. Build an API error response with the field errors.

Exercise 3: Add custom error messages to every field in a schema. Parse invalid data and verify your messages appear.

Why does .flatten() group errors by field name?

← Validating API requests Inferring TypeScript types →

© 2026 hectoday. All rights reserved.