Skip to content

Reading validation errors

A failed parse hands back an error. Logged straight to the console, it looks like a wall of text and a dead end.

It isn't. Everything you need to put a message under the right form field is in there, in a shape that's designed to be read by code.

Start from a schema that two values can break:

js
import * as z from 'zod'

const teacherSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
})

const result = teacherSchema.safeParse({ name: 12345, age: 13 })

The issues array

The error's useful contents live on .issues, and it's an array because one parse can find several problems at once:

js
console.log(result.error.issues.length)
// 2

Two values were wrong, so there are two issues. Each one is an object describing a single problem:

js
console.log(result.error.issues[0])
// {
//   expected: 'string',
//   code: 'invalid_type',
//   path: [ 'name' ],
//   message: 'Invalid input: expected string, received number'
// }

Four fields worth knowing:

FieldWhat it tells you
codeThe kind of failure, such as invalid_type or too_small
pathWhich key failed, as an array
messageA sentence describing the problem
expectedWhat the schema was looking for, on type failures

The scanner comparison holds up here. The console message is the summary the machine prints on its display. issues is the detailed report underneath, and that's the one you build with.

JunoThe issues array The confusing part is that logging the error shows you a message, so it looks like a message is all there is.

The array is there the whole time. Reach for .issues and you get the structured version, one entry per thing that was wrong.

JunoThe issues array Two things follow from it being an array. Validation doesn't stop at the first failure, so a form can show every problem at once instead of making someone fix one field per submission.

And issues[0] is a shortcut you'll regret. Reaching for the first entry works while you're testing with one broken field and quietly hides the rest the moment a real user gets two wrong.

JunoThe issues arraycode is the field to branch on when behaviour has to differ, because it's stable in a way messages are not. A too_small on a password is a user correcting themselves; a run of invalid_type on every field usually means a client is sending the wrong content type entirely, and is worth logging differently.

Issue objects carry extra keys depending on the code. A too_small issue includes minimum and inclusive, verified on Zod 4.5.4, which lets you write "must be at least 18" once and read the number off the issue rather than repeating it in a message string.

One rule for the response, though: what you show the client and what you log are different documents. Field names and constraints are fine to return. The received value is not, because rejected input includes passwords and tokens, and an error response is a place people forget they're writing data down.

Which field failed

path is an array rather than a string, because a key can be nested:

js
const orderSchema = z.object({
  user: z.object({
    profile: z.object({
      email: z.email(),
    }),
  }),
})

const bad = orderSchema.safeParse({ user: { profile: { email: 'nope' } } })

console.log(bad.error.issues[0].path)
// [ 'user', 'profile', 'email' ]

For a flat form the first element is the field name, which is enough to build what a form needs:

js
const errors = result.error.issues.map((issue) => ({
  field: issue.path[0],
  message: issue.message,
}))

console.log(errors)
// [
//   { field: 'name', message: 'Invalid input: expected string, received number' },
//   { field: 'age', message: 'Too small: expected number to be >=18' }
// ]

A field name and a sentence, one per problem. That's the shape a form needs to put each message beside the input it belongs to.

JunoWhich field failed An array looks like overkill for naming one field, until the data has layers.

['user', 'profile', 'email'] is a set of directions: go into user, then profile, then email. A plain string could not say that without you having to take it apart again.

JunoWhich field failedpath[0] is right for a flat form and wrong the moment anything nests, where it collapses every field under one parent into the same key and the messages land in the wrong place.

issue.path.join('.') gives you user.profile.email, which stays unique. Worth writing that way from the start, since it costs nothing and survives the first nested object.

JunoWhich field failed Array indices appear in the path as numbers, so a failure inside a list gives you something like ['items', 2, 'quantity']. Join that and you get items.2.quantity, which is fine for a log line and not what a form library expects; most want items[2].quantity. Worth handling once in whatever maps issues to your form.

Zod ships a helper for the common case: z.flattenError(result.error) returns { formErrors, fieldErrors }, where fieldErrors maps each top-level key to an array of message strings. Verified on Zod 4.5.4. It's the fastest route to a form, and it flattens away nesting by design, so it suits a flat form and not a deep one. Note the Zod 3 form was a .flatten() method on the error; in Zod 4 it's the top-level function.

Writing your own messages

The default messages describe the type system, not your form. "Invalid input: expected string, received number" is accurate and belongs to nobody's user.

Almost every Zod method takes a message as an argument:

js
const teacherSchema = z.object({
  name: z.string('Please enter your name'),
  age: z.number().min(18, 'Teachers must be at least eighteen'),
})

const result = teacherSchema.safeParse({ name: 12345, age: 13 })

console.log(result.error.issues.map((issue) => issue.message))
// [ 'Please enter your name', 'Teachers must be at least eighteen' ]

The message replaces the default for that check only. Each constraint carries its own, so a field can say one thing when it's missing and another when it's too short.

JunoWriting your own messages Write these the way you'd say them to the person filling in the form. "Please enter your name" beats anything mentioning types.

The reader isn't debugging your schema. They're trying to sign up.

JunoWriting your own messages Because the message is per check rather than per field, a field with three constraints needs three messages, and skipping one leaves a default in the middle of your careful copy.

Say what to do rather than what went wrong. "Use at least 12 characters" is actionable; "String must contain at least 12 character(s)" makes a person work out what you meant.

JunoWriting your own messages Custom messages are also the point where an error stops being safe to hand back unchanged. A message you wrote is yours; a default one is a description of your schema, and a client collecting them learns the exact shape and constraints of your API.

That's low-severity on a signup form and worth thinking about on an internal endpoint. The pattern that scales is a message on every check the client should see, and a generic response for anything that fails without one.

For localisation, per-call strings are the wrong layer, since you'd be threading a language through every schema definition. Zod supports a global error map instead, so translation happens once at the point where you format issues for a response.

Try it

Given this schema and a character that breaks both of its rules:

js
const characterSchema = z.object({
  name: z.string('Every character needs a name'),
  episode: z.number().min(1, 'Episodes start at 1'),
})

const character = { name: 42, episode: 0 }

Six blanks, marked ___. Each of success, result, error, issues, path and message fits exactly one of them:

js
const ___ = characterSchema.safeParse(character)

if (result.___) {
  console.log('All good')
} else {
  console.log(
    result.___.___.map((issue) => ({
      field: issue.___[0],
      message: issue.___,
    })),
  )
}
Compare your answers
js
const result = characterSchema.safeParse(character)

if (result.success) {
  console.log('All good')
} else {
  console.log(
    result.error.issues.map((issue) => ({
      field: issue.path[0],
      message: issue.message,
    })),
  )
}

Working through it in order:

  • The first blank names what safeParse returns, and everything below reads result, so it has to be result.
  • result.success is the boolean that decides which branch runs.
  • result.error only exists on the failing branch, which is why it sits inside the else.
  • .issues is the array, so .map needs it.
  • issue.path[0] is the field name, indexed because path is an array.
  • issue.message is the sentence.

The chain reads as one sentence once it clicks: the result's error's issues, each with a path and a message.

Both rules break, so it logs two entries:

js
// [
//   { field: 'name', message: 'Every character needs a name' },
//   { field: 'episode', message: 'Episodes start at 1' }
// ]

Where this goes next

Three chapters of Zod, all of it run by hand on made-up objects. The schema is doing real work and nothing is connected to the application yet.

Where validation belongs connects it to the application, putting the check between an incoming request and the code that acts on it.