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:
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:
console.log(result.error.issues.length)
// 2Two values were wrong, so there are two issues. Each one is an object describing a single problem:
console.log(result.error.issues[0])
// {
// expected: 'string',
// code: 'invalid_type',
// path: [ 'name' ],
// message: 'Invalid input: expected string, received number'
// }Four fields worth knowing:
| Field | What it tells you |
|---|---|
code | The kind of failure, such as invalid_type or too_small |
path | Which key failed, as an array |
message | A sentence describing the problem |
expected | What 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.
The array is there the whole time. Reach for .issues and you get the structured version, one entry per thing that was wrong.
Which field failed
path is an array rather than a string, because a key can be nested:
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:
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.
['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.
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:
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.
The reader isn't debugging your schema. They're trying to sign up.
Try it
Given this schema and a character that breaks both of its rules:
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:
const ___ = characterSchema.safeParse(character)
if (result.___) {
console.log('All good')
} else {
console.log(
result.___.___.map((issue) => ({
field: issue.___[0],
message: issue.___,
})),
)
}Compare your answers
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
safeParsereturns, and everything below readsresult, so it has to beresult. result.successis the boolean that decides which branch runs.result.erroronly exists on the failing branch, which is why it sits inside theelse..issuesis the array, so.mapneeds it.issue.path[0]is the field name, indexed becausepathis an array.issue.messageis 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:
// [
// { 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.

