Skip to content
Chapter 42Lesson 5

parse, safeParse, and the error contract

How you run a Zod schema and route its outcome, the throw-or-return choice and the ZodError shape that carries validation failures back to your forms.

You have spent four lessons declaring schemas. Now picture where they actually get used. A Server Action holds createInvoiceSchema, and an unknown value has just arrived off the wire: a form submission the user typed, parsed into a plain object. The schema is built. The question is how you run it, and what you get back when the input is wrong.

Two methods run a schema, and they answer one question. When validation fails, should the failure come back to you as a value, or should it interrupt the program? That single choice has a right answer for every boundary in the rest of this course, and it follows one rule you’ll learn here. By the end you’ll know which method every Server Action, route handler, and webhook reaches for, and how an error message travels from the schema where it’s written to the form where it’s shown.

Before any method name, hold this picture. Running a schema against an unknown input forks into exactly two outcomes. Either the input is valid, and you get back a fully typed value you can trust: email is a string, quantity is a positive integer, every field is what the schema promised. Or the input is invalid, and you get a structured ZodError describing what was wrong. Valid one way, ZodError the other. That fork is the whole lesson. Everything else is a question of how you want the fork delivered.

There are two delivery styles. The fork can arrive as control flow: the invalid branch throws, the program jumps to the nearest error handler, and you never write an if. You just call the schema, and either the valid value comes back or it doesn’t. Or the fork can arrive as a value: the invalid branch is returned to you, packaged as a result object you branch on yourself. Control flow versus value is the parse versus safeParse axis, and it’s the decision this lesson spends most of its time on.

There’s a second axis, worth naming once so you can set it aside. Validation can be synchronous or asynchronous. Almost everything you’ll write is synchronous: the schema looks at the input and decides immediately. But a refinement can return a Promise, say a check that has to ask the database whether an email is already taken, and the moment a schema contains such a refinement, you need an async-aware runner. This course keeps those network-touching checks out of the schema and in the action body after the parse, exactly as the lesson on checks and transforms argued. So the synchronous forms are the default everywhere, and the async ones are an escape hatch you’ll meet in a single paragraph later.

Cross those two axes and you get the four method names Zod ships. They are not four things to memorize. They are two binary choices.

Throws on failure
Returns a result
Synchronous
parse
the value came back, or an exception did.
safeParse
the result is { success }; you branch on it.
Asynchronous
parseAsync
same as parse, but awaits async checks.
safeParseAsync
same as safeParse, but awaits async checks.
Four method names, two binary choices: throw or return, sync or async.

The trust boundary decides which column you reach for, and that’s the subject of the next two sections. The row is almost always the top one. We’ll take the columns one at a time, starting with the one you’ll write the most.

schema.safeParse(input) never throws. Whatever you hand it, it hands you back an object, and that object’s shape is the key to everything in this section:

{ success: true; data: T } | { success: false; error: ZodError }

Look closely at that type. It’s a union of two object shapes, told apart by a single field: success. When success is true, there’s a data field carrying the parsed value; when success is false, there’s an error field carrying the ZodError. This is a discriminated union, the exact pattern you met when typing application state, with success playing the discriminant, the same role status or kind played there.

That heritage is the whole reason the result is safe to work with, and it’s worth being precise about why. When you write if (!result.success), TypeScript narrows: inside that block it knows result is the failure shape, so result.error is available and result.data is not. The payoff comes after that block. Having ruled out failure, TypeScript narrows result to the success shape, so result.data is present and typed as the parsed Invoice. The branch isn’t bookkeeping you do to satisfy Zod; it’s the thing that earns you a typed .data. No cast, no as, no !. You proved the success case by eliminating the failure case, and the type follows.

So every untrusted boundary in this course is the same four lines:

const result = createInvoiceSchema.safeParse(input);
if (!result.success) {
return { ok: false }; // hand the failure back to the caller (fleshed out next chapter)
}
const invoice = result.data;
// invoice is the typed Invoice shape here — trusted from this line on

Read the shape, because you’ll write it hundreds of times. Call safeParse. If it failed, return the failure to whoever called you and stop. Past the guard, result.data is the parsed value, fully typed, and every line below it is in trusted territory. The schema did its one job, turning an unknown into either a typed value or a described failure, and the four lines did the other, routing each outcome.

The rule is short. Reach for safeParse everywhere user input arrives: form submissions, request bodies, webhook payloads, a searchParams object off a URL. The reasoning is what makes it stick: at all of these places, the caller owns the failure. A form submission with a bad email isn’t your action’s emergency; it’s the user’s, and your action’s job is to hand the problem back so the form can show it next to the offending field. A failure the caller has to inspect and react to must be a value the caller can hold, not an exception that flies past them and unwinds the stack.

That handing-back is a seam worth naming, because you’ll wire it for real one chapter from now. In a Server Action, this exact result maps onto the action’s return: the success branch returns the data the action produced, and the failure branch returns the validation error in a standard shape the form can read. That standard return shape is a Result type with ok and err helpers, the next chapter’s subject. For now, just see that the seam sits right where the safeParse branch already is.

Now make the fork concrete. The exercise below runs a real safeParse over createInvoiceSchema against a row of fixtures, some the contract should accept and some it should reject, and the table flips a check or a cross per input as you edit.

This schema is the contract for a new invoice. Each fixture below forks to ✓ (the contract accepts it) or ✗ (it rejects it). One fixture should pass but doesn't: the contract rejects an `overdue` status it ought to allow. Widen the `status` enum to include `'overdue'` so the overdue invoice passes — and leave the other rows exactly as they fork. The fix is to the contract, not the form.

Booting type-checker…
Test scenario Value
valid invoice {"email":"ada@acme.test","quantity":2,"status":"sent","ta…
overdue status {"email":"ada@acme.test","quantity":2,"status":"overdue",…
unknown status {"email":"ada@acme.test","quantity":1,"status":"archived"…
negative quantity {"email":"ada@acme.test","quantity":-3,"status":"draft","…
missing email {"quantity":1,"status":"draft","tags":[]}

The other column is parse, and it’s deliberately narrow. schema.parse(input) returns the typed value on success and throws a ZodError on failure. There’s no success field to check, because there’s no failure branch to hold: success is the only path that returns. If the input is bad, the call doesn’t come back, and an exception leaves on its way to the nearest handler.

That sounds hostile, and at a boundary it would be. But parse has a legitimate home, a specific one: trusted, server-internal calls, where you, the server, just constructed a value and want to assert its shape before passing it on. A /lib helper validating an object it built from pieces. A startup script checking its own config file. A test asserting a fixture has the shape it claims. An environment-variable loader at boot, in env.ts, validating the process environment before the app starts. In every one of these, a failure means your own code is wrong: you built something malformed, or the deployment is misconfigured. That’s not a user mistake to be rendered politely. It’s a programmer error, and an exception flying to the framework boundary is exactly the right signal: crash loudly, surface the stack, fix the bug.

This is also why parse at an untrusted boundary is the headline beginner mistake in this whole topic. Drop a parse into a Server Action and forget that it throws, and the first user who submits a bad form doesn’t get a friendly error under the email field. They get a 500. The action threw, nothing caught it, and the framework turned an expected validation failure into a server error. The fix is not to wrap the parse in a try/catch:

try {
const invoice = createInvoiceSchema.parse(input);
// ...use invoice
} catch (error) {
// re-deriving the failure branch by hand — safeParse already gives you this
}

That try/catch is a code smell, not a fix. It re-implements safeParse by hand, and it does so badly: a bare catch swallows every throw, not just the ZodError, so a real bug downstream gets quietly miscategorized as a validation failure. The correct move was never to throw in the first place. Reach for safeParse, branch on the result, and you’re done.

Here’s the principle underneath both methods, the same two-channel rule that governs error handling across the whole codebase: return the expected, throw the unexpected. A user submitting an invalid form is expected, since invalid input is a normal, daily event a SaaS app must handle gracefully, so it travels the return channel, which is safeParse. A value the server built for itself failing validation is unexpected, since it should never happen if the code is correct, so it travels the throw channel, which is parse. The method you pick isn’t a style preference. It’s a statement about whether this failure is normal or impossible.

So the real decision isn’t “form or not.” It’s a short interrogation, asked in a specific order. Walk it.

parse or safeParse?

Notice the two safeParse leaves. The trust boundary is the first cut, but it isn’t the only one. If you ever need to react to a failure rather than crash on it, you need it as a value, which is safeParse, even for a value that came from inside. parse earns its place only at the intersection of trusted and should-never-fail. That intersection is small, which is exactly why safeParse is what you’ll write almost everywhere.

One check before moving on.

A webhook handler receives a payload from a third-party service you don’t control. You can’t verify who sent it until you’ve checked the signature, the user sees nothing — on a bad shape the handler logs it and responds 400. Which method parses the payload?

parse, because a malformed webhook is a serious problem worth crashing on.
parse wrapped in a try/catch that returns 400 from the catch block.
safeParse, then return 400 when result.success is false.

The bottom row of the grid exists for one situation, worth a few sentences so it never catches you off guard. If any refinement in your schema returns a Promise, an async check that has to await something, then the synchronous parse and safeParse cannot run it. They don’t quietly skip the async check or wait for it; they throw a runtime error the instant they hit one, before validation has even finished. The fix is to use parseAsync or safeParseAsync, which await the schema, and to await the call yourself.

const schema = z.string().refine(async (slug) => isSlugFree(slug));
schema.parse('my-invoice'); // throws: encountered async refinement; use .parseAsync

In practice you’ll rarely meet this, and that’s by design. This course’s rule, established in the lesson on checks and transforms, is to keep anything that needs the network or the database (uniqueness, slug-availability, plan-permission checks) out of the schema and in the action body, after the parse, where database access is plainly legitimate. That’s precisely why the synchronous forms work everywhere: nothing in a well-built schema is async, so safeParse is always enough. The async variants are a named escape hatch, not a daily tool. The one place they legitimately surface later in this course is validating the inputs an LLM hands to a tool, where an async check can occasionally earn its place. That’s far off, though, and the pointer is all you need now.

You’ve been treating ZodError as a black box: the thing the failure branch carries. It’s worth opening, because the form layer is built directly on its contents, and when you need custom rendering you’ll read those contents by hand.

A ZodError carries an issues array, and the word array is the first thing to internalize. One bad input can produce several issues. Submit a form with three fields wrong and you get one ZodError with three entries in issues, one per failure. The error isn’t “the parse failed”; it’s “here is every way the parse failed,” itemized.

Each issue is an object, and four fields carry the weight. code names the kind of failure: invalid_type when a field is the wrong type, too_small when a number or string is under its minimum, invalid_format when an email or URL doesn’t match its format, unrecognized_keys when a strict object got a key it didn’t expect, and custom for a refinement you wrote. message is the human-readable string, and crucially it’s the string the schema authored, the thread the next two sections pull on. There are also code-specific fields: an invalid_type issue carries expected and received, a too_small issue carries minimum, and so on. That’s the structured data behind the message, so a renderer can rebuild the wording if it wants to.

The fourth field is the one that makes forms possible. Each issue has a path : an array that pinpoints which field failed, descending into nested objects and arrays. A top-level email field that’s invalid produces path: ['email']. A quantity on the first line item of an invoice produces path: ['lines', 0, 'quantity']. This is how a message gets anchored under the right input: the form reads the path and knows exactly which field to render the message beside.

And here the two halves of the contract meet. Remember the cross-field refinement from the lesson on checks and transforms, the password-confirmation check that wrote .refine(fn, { path: ['confirm'] }) to aim its error at the confirm field. That path you set is this path you’re now reading. The schema sets the path, the ZodError carries it, and the form reads it: one value, authored on one side and consumed on the other. Here’s a real one.

[
{
code: 'invalid_format',
format: 'email',
path: ['email'],
message: 'Enter a valid email address',
},
{
code: 'too_small',
minimum: 8,
path: ['password'],
message: 'Password must be at least 8 characters',
},
]

This is error.issues, an array. Two entries here because two fields failed, one entry per failure, always.

[
{
code: 'invalid_format',
format: 'email',
path: ['email'],
message: 'Enter a valid email address',
},
{
code: 'too_small',
minimum: 8,
path: ['password'],
message: 'Password must be at least 8 characters',
},
]

The first issue. code names the failure kind; here the email didn’t match its format.

[
{
code: 'invalid_format',
format: 'email',
path: ['email'],
message: 'Enter a valid email address',
},
{
code: 'too_small',
minimum: 8,
path: ['password'],
message: 'Password must be at least 8 characters',
},
]

The field anchor. The form reads this to place the message under the email input. ['email'] means “the top-level email field.”

[
{
code: 'invalid_format',
format: 'email',
path: ['email'],
message: 'Enter a valid email address',
},
{
code: 'too_small',
minimum: 8,
path: ['password'],
message: 'Password must be at least 8 characters',
},
]

The human string, and note that it’s the one the schema authored, not a Zod default.

[
{
code: 'invalid_format',
format: 'email',
path: ['email'],
message: 'Enter a valid email address',
},
{
code: 'too_small',
minimum: 8,
path: ['password'],
message: 'Password must be at least 8 characters',
},
]

A different code (too_small) carries different data. minimum is the structured value behind “at least 8 characters,” there for a renderer that wants to rebuild the wording.

1 / 1

For the common case you won’t read issues by hand. There’s a shortcut, coming next, that reshapes the whole array into something a form walks directly. But the array is always there underneath, and when the form layer needs full control (custom grouping, showing only the first error per field, severity levels) it reads issues itself. Know that the raw material exists, and reach for the shortcut by default.

The shortcut is z.treeifyError. Hand it a ZodError and it returns a nested object that mirrors the shape of the input you tried to parse. Not the flat issues array, but a tree, keyed the way your data is keyed.

That phrase, “mirrors the shape of the input,” is the whole idea, so here it is as a picture before any access code.

The input you parsed
{
treeifyError(error)
{
errors: [],
properties: {
email ,
email { errors: ['Enter a valid email address'] },
password ,
password { errors: ['Password must be at least 8 characters'] },
lines : [ … ],
lines { errors: [], items: [ … ] },
}
},
}
The error tree has the same skeleton as the data, which is why the form can walk it with the field paths it already uses for inputs.

The tree has a fixed grammar. At the top, an errors array holds form-level issues, failures not tied to any single field (an empty path lands here). Alongside it, a properties object holds one entry per field, and each entry has its own errors array, nesting further into properties or items for nested objects and arrays. To render the message under the email input, the form reaches in for that field’s errors array and takes the first one.

That reach is where people trip, so here it is exactly rather than left to guesswork:

const tree = z.treeifyError(result.error);
tree.properties?.email?.errors?.[0];
//=> 'Enter a valid email address'

Mirrors the input shape. Reach for it when fields nest. The optional chaining isn’t decoration: properties, the field key, and errors are each present only when that field actually has an issue, so every hop needs the ?..

Two output shapes, one failed parse. treeifyError gives you the nested tree; flattenError gives you a single-level { formErrors, fieldErrors } object that’s perfect when the form has no nesting to mirror. The experienced call is to match the tool to the schema: treeifyError for the nested schemas a real SaaS form has (line items, addresses, repeating groups) and flattenError only when the schema is flat and the extra nesting would buy you nothing. Don’t mix them within a project, though; pick one and let every form read the same shape. This course uses treeifyError, and so will every example from here on.

If you’ve seen Zod in an older codebase, you may know two methods that did this job before, and you should recognize them as legacy. Zod 3 had error.format() and error.flatten(), instance methods on the error. Zod 4 replaces them with the top-level functions you just saw:

error.format(); // Zod 3
z.treeifyError(error); // Zod 4
error.flatten(); // Zod 3
z.flattenError(error); // Zod 4

Both v3 methods are deprecated, and both have a one-to-one v4 replacement. When you meet error.format() in an old file, it’s a z.treeifyError(error) waiting to happen. From here on, only the v4 form appears.

One more top-level helper, named for a single situation. z.prettifyError(error) returns a human-readable, multi-line string: the whole error laid out legibly. It’s not for the UI; it’s for a server log or a script’s stderr, the times you want to read the entire failure at a glance rather than render it field by field. You won’t reach for it often, but when a log line needs the full error in one readable block, that’s the one.

You’ve now seen message ride through every layer: authored in the issue, carried in the tree, read by the form. Here is the rule that makes that flow work, the rule the whole chapter has been building toward: error messages live on the schema, never in the form component.

Think about what that buys you. When a new validation rule lands, a field gets a minimum length or an email gets a stricter format, you add it and its message in one place: the schema. The form picks the message up automatically through the tree, because the form never wrote the wording in the first place. The form’s job is to place messages, not to author them. It doesn’t second-guess what email validation should say; it reads whatever the schema decided and renders it under the email input. The schema authors, the form renders: two jobs, one source of truth, no drift.

The tool that does the authoring is Zod 4’s unified error option. It’s a single surface, and it shows up in a few forms. The simplest is a string, one message for any failure from this schema:

const signupSchema = z.object({
name: z.string({ error: 'Name is required' }),
email: z.email({ error: 'Enter a valid email address' }),
});

When you need different wording for different kinds of failure on the same field, the error option takes a function instead. The function receives the issue, the very object you dissected two sections ago, and returns a string, inspecting the issue to tell the failures apart:

const name = z.string({
error: (issue) =>
issue.input === undefined
? 'Name is required'
: 'Name must be text',
});

That issue.input === undefined check deserves a beat, because it replaces something you might look for and not find. Zod 4 has no required issue code. A missing required field isn’t its own kind of failure; it’s an invalid_type where the input happens to be undefined. So “was this field left blank?” is answered by checking issue.input === undefined, and that single check is how you author a distinct “this field is required” message. It reads like a small idiom, but it’s the way v4 expresses required, not ceremony layered on top.

The same error option works on a refinement: .refine(fn, { error: 'Passwords must match' }). You already wrote that in the lesson on checks and transforms without a name for it. Now it has one, and it was never a refinement-only feature. error is the single, unified surface for authoring messages, and a refinement is one more place it appears.

If you’ve used Zod 3, this unified option is a cleanup of something messier. Version 3 had three separate parameters for message customization, and version 4 collapses all three into the single error param:

const name = z.string({
message: 'Invalid name',
invalid_type_error: 'Name must be text',
required_error: 'Name is required',
});

Three separate params for three situations. They couldn’t be combined with an error map, and required_error/invalid_type_error didn’t correspond to real issue codes: there is no required code under the hood.

The collapse isn’t cosmetic. The old invalid_type_error and required_error are dropped in v4: they didn’t correspond to real issue codes (there’s no required code, as you now know), and they couldn’t compose with a custom error map. The old message still works but is deprecated. A legacy schema using the three-param form needs a one-shot rewrite to the single error. Write only the v4 form in new code.

There are two narrower places to set a message, each worth knowing exists. You can override messages for a single parse call with schema.safeParse(input, { error: (issue) => '...' }), for when one schema needs different wording in different contexts, like a localized run versus a default one. And you can set a process-wide default mapper with z.config({ customError: (issue) => '...' }), wired once at startup. That global hook is exactly where the internationalization layer plugs in later in this course to translate every default message at once. Both are named here, not drilled; reach for them when the moment comes.

One check on the v3-to-v4 shift.

A legacy schema customizes one field’s messages the Zod 3 way:

z.string({
invalid_type_error: 'Must be text',
required_error: 'This field is required',
});

Both of those params are dropped in Zod 4. Which single change replaces them?

Keep both params — Zod 4 still reads invalid_type_error and required_error, just with a deprecation warning.
Move the type check into a .refine() and keep required_error for the missing-field case.
One error function that returns the required message when issue.input === undefined and the type message otherwise.

When the failure isn’t the user’s fault

Section titled “When the failure isn’t the user’s fault”

There’s one issue the form should not render, and it teaches a real distinction: not every validation failure is something a user can fix.

Recall the strict object, z.strictObject, the senior default for a request body, which rejects any key it didn’t declare. When that rejection fires, it produces an unrecognized_keys issue:

{
code: 'unrecognized_keys',
keys: ['isAdmin'],
path: [],
}

Here’s the thing about that issue: the user can’t cause it through the form. The visible inputs map to the schema’s declared fields, and there’s no input on the screen for isAdmin. An unexpected key means something else happened: a stale client still sending a field you renamed, a request someone tampered with by hand, or two versions of your contract that have drifted apart. None of that is a mistake the person at the keyboard made, and none of it has a field to anchor a message to. Notice that the issue’s path is empty: there’s nowhere on the form to put it.

So this issue isn’t a field error at all; it’s an operator signal. The form ignores it, because there’s no input it belongs beside. The action logs it, because a drifted contract is exactly the kind of thing you want to know about, the sort of detail you’d need at 3am to reconstruct why a client started failing. Surfacing these to monitoring is a later concern in this course; for now, the point is the split itself.

And that split generalizes past this one case. The path doesn’t just tell you where to render; it tells you whether to render. An issue whose path points at a real input is a field error: show it, the user can fix it. An issue with an empty path, or a key with no matching input, is an operator-facing signal: log it, the user can’t do anything about it. The form renders what the user can fix, and everything else is for whoever’s on call.

That’s the error contract, end to end. The schema authors the message and the path. The parse forks: safeParse returns the fork as a value at every boundary, and parse throws it at the trusted edge. The result carries a ZodError whose issues each pinpoint a field. treeifyError reshapes that into a form-shaped tree. And the form reads the tree, rendering what the user can fix and leaving the rest for the logs. Every action and route handler in the rest of this course is built on this seam.