Result, or throw
The error-handling contract for Server Actions, when to return a typed Result the form can render and when to throw to the framework.
Your createInvoice action has parsed its input and reached its body. It is doing real work now, and three things can go wrong, not because you wrote a bug but because this is what a public mutation endpoint does for a living:
- the email field made it past the browser but fails your schema,
- the slug collides with a row that already exists, and the database rejects the insert,
- the caller has no valid session.
Each of these has to land somewhere a person can see it. “Invalid email” belongs under the email field. “That slug is taken” belongs in a banner at the top of the form. “Please sign in” sends them to the login page. In every case the user should stay roughly where they are, with what they typed intact, and read a message that tells them what to do next.
So here is the question this lesson answers. When something fails inside an action, does the action throw and let some outer layer catch it, or does it return a value that the form reads and renders? The answer the course commits to, in one line: you return a typed result for failures the user can fix, and you throw only at the edge where the framework is the one catching. You met discriminated unions back in the TypeScript chapter, and this is the lesson where they stop being a language feature and start earning their keep. By the end you will be able to write createInvoice’s full return contract and, for any failure that comes up, decide which of the two channels it travels.
Two channels for failure
Section titled “Two channels for failure”Before any TypeScript, get the model straight, because the model is the whole lesson. An action that fails has exactly two ways to report it, and they go to two different places.
Channel one is return. These are the failures the action expects. They are part of its normal job, not a surprise: a field is invalid, a unique constraint fired, a business rule said no, or the record the user asked for isn’t there and you want to tell them so inline. None of these are bugs. They are ordinary outcomes of accepting input from the outside world, and the action hands them back as a value. The form, which called the action, branches on that value and renders the right message. The user never leaves the page.
Channel two is throw. This one has two sub-cases that look the same but mean different things, and conflating them is where people go wrong.
The first sub-case is a genuine error, something the form cannot recover from no matter what the user does. The database is unreachable. An environment variable that should always be set is missing. An invariant you assumed could never break, broke. These are programmer or infrastructure problems, and the right destination for them is the global error page, which the App Router renders from the nearest error.tsx . There is nothing useful the user can type to fix a downed database, so you don’t pretend there is.
The second sub-case isn’t an error at all, even though it also raises. notFound() and redirect() are framework conventions. They raise to interrupt the function, which is how they do their job, but Next.js catches them and turns them into a 404 render or a 303 redirect. They live on the throw path because of how they work, not because anything went wrong. Name them now so that “throw” doesn’t quietly come to mean “only for bugs” in your head.
That gives you the sentence the rest of this lesson hangs off:
This is not a personal preference you can swap out. It is the house rule: the course’s code conventions open the error-handling section with exactly this line, and every action you write from here on obeys it.
Getting this single decision wrong is expensive in a way the user feels immediately. Picture the duplicate-slug case. The user fills out a long invoice form, hits submit, and the database rejects the slug. If your action throws on that, the throw sails past the form, hits error.tsx, and the user is staring at a full-page error screen with everything they typed gone. They have to start over from a blank form to fix a one-word collision. That is the concrete cost of putting a failure on the wrong channel, and it is exactly the pain the discipline in this lesson removes.
Here is the picture to keep in your head: the action body is one box with two exits, and the exit you pick determines where the user lands.
error.tsx global error page return hands a
value back to the form; throw heads to error.tsx; redirect()
/ notFound() ride the throw mechanism but aren’t errors at all.
The canonical Result shape
Section titled “The canonical Result shape”Now the type. You will not invent it: it already lives in the codebase, at lib/result.ts, and every action imports the same one. Inventing a private { success, error } shape per action is precisely the thing the conventions forbid, because then the form has to learn a new contract for every action it talks to. There is one Result, and we are going to read it field by field so each piece earns its place.
Rather than drop the full nested type on you cold, watch it grow.
Start with the bare split. A result is either a success or a failure, and nothing in between:
type Result<T> = { ok: true; data: T } | { ok: false; error: /* … */ };The ok field is the discriminant, the single boolean that TypeScript narrows the union on. This is the discriminated union you met in the TypeScript chapter, doing the one job it is best at. Because ok is a literal true on one branch and a literal false on the other, the moment you check if (result.ok) TypeScript knows you are on the success branch and gives you data; in the else, it knows you are on the failure branch and gives you error. The two can never be present at once, and neither can be missing on its own branch. That is what making impossible states unrepresentable means in practice: a “successful result with an error and no data” is not a value this type can hold.
Contrast that with the shape you will see in a lot of code and should not write:
type Loose = { success: boolean; data?: T; error?: E };Everything is optional, so the type permits states that make no sense: data and error both set, or both undefined. Now every consumer has to defensively check both fields and guess what a { success: true } with no data means. The union removes the guessing by removing the impossible states. Reach for { ok: true; data } | { ok: false; error } over { success, data?, error? } every time.
Now build out the failure branch. A bare error isn’t enough, because the failure has to carry information for two different audiences, and they want different things.
The first audience is your other code. When the form receives a failure, it needs to decide how to render it: a conflict shows a banner, a validation failure shows messages under fields. It cannot make that decision off a human sentence. So the error carries a code, a short, stable, machine-readable string the form switches on. Analytics wants the same thing later: group failures by code to see how often conflict fires.
The second audience is the person at the keyboard. They will never see the code. They read a userMessage, a human sentence the form renders verbatim, exactly as the action wrote it.
And when the failure is about specific fields, the error carries fieldErrors, a map from a field’s name to the list of messages for that field, so the form can drop each message under the right input.
Here is the whole type, exactly as it ships. Step through it.
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };The success branch. ok: true is the discriminant on this side; data is whatever the action produced, typed by the generic T so each action says exactly what it returns on success.
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };The failure branch. ok: false is the other side of the discriminant; everything the caller needs to handle a failure hangs off error.
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };The machine-readable code, a small, fixed set of strings (we go through each one shortly). This is what the form and the analytics layer branch on. It is a string-literal union, so a typo’d code is a compile error, not a runtime surprise.
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };The human sentence. The form renders this and only this, with no rephrasing and no fallback copy of its own.
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };Optional, for field-level failures. A field name maps to the list of messages for that field. Present on validation failures, absent on form-level ones like conflict.
The split between code and userMessage is the part to internalize, because it is a senior instinct, not a syntax detail. code is the contract between your layers; userMessage is what the user reads. Codes are stable and few: you branch on them, so there can only be a handful worth having. Messages are human, plentiful, and free to change. Rewording “That slug is taken” to “That slug is already in use” should never break a single if statement, and because the form branches on code and merely displays userMessage, it won’t.
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };One file holds this type. Every action imports Result from @/lib/result; no action defines its own. That is what makes the contract a contract: there is exactly one definition to read, one to change, and the form can trust it across every action in the app.
Let’s make the discriminant prove itself. In the following exercise you are handed a Result<{ id: string }> and a function that tries to read result.data.id. It won’t type-check yet, because reaching into data is only safe once you’ve checked ok. Add the narrowing and watch the error disappear.
`readId` reads `result.data.id`, but that's only safe on the success branch — so it doesn't type-check. Add an `ok` check so the error goes away.
- Fix all errors
Reaching into data without first checking ok is a type error: the union won’t let you, and that refusal is exactly the safety you’re paying for.
ok and err: two helpers so you never forget the discriminant
Section titled “ok and err: two helpers so you never forget the discriminant”Writing { ok: true, data } by hand in every action is noisy, and noise is where bugs hide: forget the ok field once and the whole union falls apart. So two tiny helpers live next to the type in lib/result.ts, and they set the discriminant for you:
export const ok = <T>(data: T): Result<T> => ({ ok: true, data });
export const err = ( code: ErrorCode, userMessage: string, fieldErrors?: Record<string, string[]>,): Result<never> => ({ ok: false, error: { code, userMessage, fieldErrors } });ok(data) gives you the success branch with the data tucked in. err(code, userMessage, fieldErrors?) gives you the failure branch with the discriminant set and the three error fields filled. With those, the action body stops being plumbing and starts reading like intent.
Here is the parse-failure branch of createInvoice, the way it reads now.
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, );}
// …authorize, then mutate (this is where `invoice` is created), then revalidate…
return ok({ id: invoice.id });This is the line that upgrades the placeholder from the previous lesson. There, this branch returned a hand-written { ok: false, error: { … } } literal flagged as a throwaway placeholder, and err() is its finished form: same shape, named constructor, discriminant guaranteed.
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, );}
// …authorize, then mutate (this is where `invoice` is created), then revalidate…
return ok({ id: invoice.id });This pulls the per-field messages out of the Zod failure in exactly the shape fieldErrors wants. Why flattenError and not the treeifyError from the previous lesson is the next paragraph’s job; it’s deliberate here, not a typo.
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, );}
// …authorize, then mutate (this is where `invoice` is created), then revalidate…
return ok({ id: invoice.id });Success hands back the new invoice’s id, not the whole row. We’ll come back to why “return small” matters; for now notice the success payload is deliberately tiny.
That second step deserves a real explanation, because it looks like an inconsistency and isn’t. Zod gives you two ways to project a validation error. z.treeifyError(error) builds a nested tree that mirrors your form’s shape, so you’d read a message at tree.properties?.email?.errors. z.flattenError(error) builds a flat { formErrors, fieldErrors } object, where fieldErrors is a Record<string, string[]>, a field name mapped to its messages. Look at the type of Result’s fieldErrors field again: it’s Record<string, string[]>. That is flattenError’s output, exactly. treeifyError’s nested tree does not fit there.
So the rule isn’t “always use one or the other.” It’s pick the error projection that matches the type your contract declares. The invoice form is one level deep and the Result.fieldErrors channel is flat, so you feed it with flattenError. A deeply nested form with array fields and sub-objects would reach for treeifyError and a contract shaped to match. (Zod’s two projections were both covered in the previous chapter; we’re only choosing between them here, not re-teaching them.)
Throw at the framework edge, return everywhere else
Section titled “Throw at the framework edge, return everywhere else”This is the decision the whole lesson is built around, so we’ll state the rule, then drill it until it’s reflex.
The rule, in one line you can memorize: throw at the framework edge; return Result inside the action body where the form branches on the shape.
Concretely, you throw when:
- The resource genuinely doesn’t exist and the right experience is the 404 page:
notFound(). Framework convention. - Navigation is the outcome. After creating the invoice you want the user on its detail page, so
redirect(`/invoices/${id}`). Framework convention; the runtime turns it into a 303. One thing to file away:redirect()raises to do its job, so it has to sit outside anytry/catchthat would swallow it. Where it goes and how it interacts with transactions is a problem the last lesson of this chapter handles. Here it’s just an example of a throw that isn’t an error. - A genuine programmer or infrastructure error happens and there’s no in-form recovery: the database is down, a required env var is missing. Let it propagate to
error.tsx. - The auth helper rejects a missing session. That helper throws or redirects, and the framework is the catch site. The pattern lands in the authentication chapter later in the course; we’re only naming the slot today.
And you return Result when the form should render the failure and the user stays put: field validation, business-rule rejections (their plan doesn’t include this, the recipient is suppressed), unique-constraint conflicts. For a SaaS mutation, this is the common case, since most failures are the user’s to fix.
Here’s the senior shortcut that collapses the whole decision into one question. Ask: can the user fix this from where they are? If yes (they correct a field and retry, they pick a different slug), it’s a Result. If no, or if the right move is to leave the page entirely, it’s a throw or a framework convention. That single question routes almost everything.
Walk it yourself. The following decision tree asks the questions in the order an experienced engineer asks them: recoverable first, then the shape of the recovery. Click through it.
The form renders each message under its input. fieldErrors carries the per-field messages, keyed by field name.
A form-level banner. Pick the code that matches the rule: conflict for a collision, and a sanctioned domain code where a real product branch exists.
A programmer or infrastructure error with no graceful in-form recovery. The user can’t type their way out of a downed database.
A framework convention, not an error. The runtime turns it into a 303. It happens before a Result would have returned.
A framework convention. Renders the nearest not-found UI. Use it when returning not_found as data isn’t the experience you want.
Now convert the rule into a reflex. Sort each of these failures into the channel its action should use.
Sort each failure into the channel the action should use. Ask yourself: can the user fix this from where they are? Drag each item into the bucket it belongs to, then press Check.
email field fails Zod validationredirect)id in the URL matches no row (notFound)undefined at runtimeA small, stable set of error codes
Section titled “A small, stable set of error codes”Look back at the code union in the Result type: it isn’t free-form. The codes are enumerated, on purpose, because code is a cross-layer contract and a contract with infinite possible values is no contract at all. Here is the canonical set and what each one means:
validation: the input failed the schema. Pair it withfieldErrors.conflict: a uniqueness or state collision. The taken slug, the duplicate row.not_found: a referenced record is missing and the action chose to hand that back as data rather than thrownotFound().unauthorized: there is no identity. The caller isn’t signed in.forbidden: there is an identity, but it lacks permission. Signed in, wrong role or wrong org.rate_limited: too many attempts in too short a window.internal: a sanitized stand-in for an unexpected failure the action decided to surface as data instead of throwing.
Two things experienced engineers hold firm on here.
First, keep the set small, roughly six to ten for the entire application. The temptation is to mint a fresh code for every action: invoice_slug_taken, customer_email_taken, org_name_taken. Resist it. The form can’t meaningfully switch on a hundred codes, and it doesn’t need to, since those three are all just conflict, distinguished by their userMessage. Codes are for branching, and there are only a few branches worth having; if a failure needs to say something specific, that’s what userMessage is for. The app can add a domain code, plan_limit say, but only when a real layer genuinely switches on it. It earns its place by being a branch, not by being a unique label.
Second, the one pair worth getting exactly right is unauthorized versus forbidden, because newcomers swap them constantly and the two map to genuinely different HTTP responses (401 versus 403). unauthorized means no identity: you don’t know who this is, so send them to sign in. forbidden means identity, but no permission: you know exactly who this is, and they’re not allowed. “Signed out” and “signed in but not on this team” are different situations that need different responses, and the code is where you keep them straight. (Enforcing them is the authentication chapter’s job; today we’re just fixing the two names.)
It’s worth pulling that union out of the Result type and giving it a name, both so the err helper can reference it and so there’s one obvious place the set lives. It’s a string-literal union, not an enum:
export type ErrorCode = | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal';Because ErrorCode is a literal union and the Result type’s code field is typed by it, every error.code you write is checked against the set. Type err('conflcit', …) and you get a red squiggle, not a silent failure that surfaces months later when the form’s code === 'conflict' branch quietly never matches. That is the whole reason the course never uses enum for sets like this: a string-literal union gives you the same exhaustiveness with none of the runtime baggage.
Map known errors to codes; never leak the raw error
Section titled “Map known errors to codes; never leak the raw error”There’s one more boundary to get right, and it has a security edge to it. The database doesn’t return a Result; it throws. When the slug collides, Postgres raises an error. Your action’s job is to catch the throws it can recognize and turn them into returned Result failures, while letting the ones it can’t recognize keep flying toward error.tsx. The tempting shortcut, catch everything and stuff the error’s message into userMessage, is a mistake, and here is why.
} catch (e) { return err('internal', (e as Error).message);}This leaks the database schema to anyone with the form open. e.message from Postgres carries constraint names, column names, and sometimes the offending values, so shipping it to the browser as userMessage hands an attacker a map of your schema.
} catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'That slug is already taken.'); } throw e;}The user gets a clean message; the operator gets the full error in the logs. Known failures become a typed code plus a written message; everything unrecognized re-throws so error.tsx (and your logging) sees the real thing.
The second variant is the pattern, and it has a name worth remembering: catch, map, re-throw. Here it is on the mutation seam of createInvoice.
try { const [invoice] = await db .insert(invoicesTable) .values(parsed.data) .returning(); return ok({ id: invoice.id });} catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'That slug is already taken.'); } throw e;}The happy path: insert the row, hand back its id. The try is here only so the next two branches can intercept what the insert might throw.
try { const [invoice] = await db .insert(invoicesTable) .values(parsed.data) .returning(); return ok({ id: invoice.id });} catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'That slug is already taken.'); } throw e;}A known failure. This maps Postgres’s unique-violation to the conflict code and a written message. isUniqueViolation is a small helper in /lib, and naming it abstractly is deliberate (next paragraph).
try { const [invoice] = await db .insert(invoicesTable) .values(parsed.data) .returning(); return ok({ id: invoice.id });} catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'That slug is already taken.'); } throw e;}The rule in one line: catch what you can name and handle; re-throw everything else. An error you didn’t recognize is not yours to translate, so let it propagate to error.tsx with its real message intact.
Two placement details matter. First, isUniqueViolation lives in /lib as a reusable helper, and we’re calling it abstractly here for a real reason, not laziness. Detecting a Postgres unique violation is not as simple as e.code === '23505'. The current Drizzle wraps database failures in a generic error and exposes the underlying Postgres error (the one carrying code: '23505') on the error’s .cause property, so a top-level e.code check reads undefined and silently never matches. That detection logic, knowing where Postgres hides the code, belongs to the database layer covered in an earlier chapter, and it belongs in one tested helper so no action has to get the .cause dance right on its own.
Second, the catch block is where the two audiences split. The user’s message and the operator’s record diverge here, at the wrapper, never up at the UI. The action writes a clean userMessage for the person, and your logging captures the full thrown error for you. This means the form has no business inventing a “Something went wrong” string of its own: if a userMessage is missing, the bug is in the action that forgot to return one, not in the form.
A quick gut-check. The following catch block runs after a unique-violation. Which return is the right one?
The insert below just threw because the slug already exists, so isUniqueViolation(e) is true. Which line belongs on the blank?
} catch (e) { if (isUniqueViolation(e)) { ___ } throw e;}return err('internal', (e as Error).message);return err('conflict', 'That slug is already in use.');throw e;return { ok: false, error: 'That slug is already in use.' };Result on the conflict code with a written sentence. The first option leaks the raw Postgres message — schema names and all — to the browser as userMessage. Re-throwing kicks a fixable conflict to error.tsx and wipes the form. The last hands back a bare string where the contract wants error to be an object with code and userMessage, so every form that reads the shape breaks.userMessage: one source of truth for failure copy
Section titled “userMessage: one source of truth for failure copy”A short but load-bearing discipline. Every Result failure carries a human string, and the form renders it verbatim, with no rephrasing, no translating, and no UI-invented fallback. The labor divides cleanly, one owner per kind of message:
- The schema authors validation messages (you wrote these in the previous chapter); they ride along inside
fieldErrors. - The action authors business-rule messages: the
userMessageon aconflict, aplan_limit, and so on. - The form authors nothing. It displays what it’s handed.
It follows that if you ever catch the form reaching for a hardcoded “Something went wrong,” that’s a signal the action failed to return a userMessage, and the fix goes in the action, not the form. The production reason is plain. UI-invented error copy drifts out of sync, never shows up in a content review, and can’t be localized from one place later. Keep the words where they’re owned. (Translating these strings is a concern for a later unit; just know the single-source rule is what makes that translation tractable.)
Return small: hand back the ID, not the row
Section titled “Return small: hand back the ID, not the row”Now close out the success side. On ok, data should be the minimal thing the caller actually needs, usually the new entity’s id, or null for a fire-and-forget mutation, and not the full Drizzle row.
The reason is the wire. A Result is the value a Server Action returns, which means it’s serialized and sent over the network on every mutation. Return a fat row, every timestamp, every column, maybe a relation or two, and you ship bytes the client never reads. Worse, you weld the wire shape to the table shape, so a new column silently changes what every caller receives. The move is return ok({ id }). The client doesn’t need the row back; it re-reads fresh data through the revalidated cache, which is exactly what the last lesson of this chapter sets up. (There’s a second reason from the first lesson of this chapter, too: a raw Drizzle row carries prototype methods that can fail serialization outright, and projecting to a plain { id } sidesteps that as well.)
This is the mirror image of “never leak the raw error.” Both are the same instinct applied to the two exits: be deliberate about what crosses the wire from an action, in success and in failure alike.
The form sees the contract
Section titled “The form sees the contract”You’ve now fixed every field in the Result. It’s worth seeing briefly who consumes them, even though wiring that consumer is not this lesson’s job.
In the next chapter, the form reads this exact contract through useActionState :
const [state, formAction, pending] = useActionState(createInvoice, null);// state?.ok === false → render state.error.userMessage as a banner// → render state.error.fieldErrors under each fieldThat’s it. The form discriminates on state.ok, reads userMessage for the banner, reads fieldErrors for the inline messages. Wiring it is the next chapter’s job; the contract it reads is what you just locked down. Every field you defined has a waiting consumer, which is the point. (One more subscriber, named once: the optimistic-update hook in that same chapter rolls its optimistic change back when the action returns ok: false. It, too, watches the ok discriminant.)
Two channels, one rule: return the expected, throw the unexpected. Expected failures (validation, conflicts, business rules, not-found-as-data) are data the form branches on, so they return a Result. The unexpected ones (programmer errors, downed infrastructure) and the framework conventions notFound() and redirect() go on the throw channel toward error.tsx. Inside the Result, each field has one job: ok is the discriminant TypeScript narrows on, code is the stable contract your layers branch on, userMessage is the sentence the user reads, and fieldErrors carries the per-field messages. Build failures with err(), successes with ok(), map known thrown errors to typed codes at the catch, never leak the raw error, and return the smallest payload the caller needs. The deciding question, for any failure, stays the same: can the user fix this from where they are?
External resources
Section titled “External resources”The canonical reference on discriminated unions and how a literal discriminant narrows a union.
How flattenError and treeifyError project a parse failure — and which one fits a flat fieldErrors.
The official return-the-expected, throw-the-unexpected split: error.tsx, notFound, and redirect.
The hook the form uses to read this Result. Wired in the next chapter.