Result, or throw
The error-handling contract for Server Actions, return a typed Result or throw to the framework.
Your createInvoice action has parsed its input and reached its body, where it does the real work. Three things can go wrong here, none a bug in your code:
- the email field passed the browser but fails your schema,
- the slug collides with an existing row, and the database rejects the insert,
- the caller has no valid session.
Each failure has to reach the user somewhere they can act on it: “Invalid email” under the email field, “That slug is taken” in a banner above the form, “Please sign in” on the login page. The user stays put, keeps what they typed, and reads what to do next.
So when an action fails, does it throw for an outer layer to catch, or return a value the form reads and renders? You return a typed result for failures the user can fix, and throw only at the edge where the framework is the catcher. The discriminated unions from the TypeScript chapter make that return value safe to read.
Two channels for failure
Section titled “Two channels for failure”A failing action has exactly two ways to report itself, and each sends the user somewhere different.
Channel one is return. These are the failures the action expects as part of its job: a field is invalid, a unique constraint fired, a business rule said no, or the record isn’t there and you want to say so inline. The action hands the failure back as a value, the form branches on it and renders the message, and the user never leaves the page.
Channel two is throw, which covers two cases that both raise but mean different things.
The first is a genuine error the user cannot fix no matter what they type: the database is unreachable, a required environment variable is missing, an invariant broke. These are programmer or infrastructure problems, so they go to the global error page that the App Router renders from the nearest error.tsx .
The second isn’t an error at all. notFound() and redirect() are framework conventions that raise to interrupt the function; Next.js catches them and turns them into a 404 render or a 303 redirect. They sit on the throw path because of how they work, not because anything went wrong.
That gives you the rule:
Get the channel wrong and the user feels it at once. Take the duplicate slug: the user fills out a long invoice form, submits, and the database rejects the slug. If the action throws, the throw sails past the form to error.tsx, dropping the user on a full-page error screen with everything they typed gone, made to start over for a one-word collision.
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 shared Result type
Section titled “The shared Result type”You will not invent this type. It lives at lib/result.ts, and every action imports the same one, because giving each action its own { success, error } shape forces the form to learn a new contract for every action it calls.
A result is either a success or a failure, with nothing in between:
type Result<T> = { ok: true; data: T } | { ok: false; error: /* … */ };The ok field is the discriminant: a literal true on one branch and a literal false on the other. Check if (result.ok) and TypeScript narrows to the success branch and gives you data; the else gives you error. This is the discriminated union from the TypeScript chapter making impossible states unrepresentable: a “success with an error and no data” is not a value this type can hold.
Compare the shape you will see in a lot of code and should not write:
type Loose = { success: boolean; data?: T; error?: E };Every field is optional, so the type permits nonsense: both set, or both undefined. Every consumer then has to check both fields and guess what { success: true } with no data means. Reach for { ok: true; data } | { ok: false; error } every time.
Now the failure branch, which carries information for two audiences. Your other code reads a code: a short, stable, machine-readable string the form switches on to choose a banner or field messages, and that analytics groups by. The person at the keyboard never sees the code; they read userMessage, a human sentence the form renders verbatim. When the failure is about specific fields, the error also carries fieldErrors, a map from field name to its list of messages.
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; data is typed by the generic T, so each action says exactly what it returns.
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 needed 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 fixed set of strings the form and analytics branch on (we cover each shortly). As a string-literal union, 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 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 its list of messages. Present on validation failures, absent on form-level ones like conflict.
The split between code and userMessage 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, since you branch on them; messages are human, plentiful, and free to change. Rewording “That slug is taken” to “That slug is already in use” should never break an if, and since the form branches on code and only 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[]>; }; };The exercise hands you a Result<{ id: string }> and a function that reads result.data.id. It won’t type-check, 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
That refusal is the safety you’re paying for.
ok and err constructors
Section titled “ok and err constructors”Writing { ok: true, data } by hand in every action invites bugs: forget ok once and the union breaks. Two helpers in lib/result.ts 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) returns the success branch; err(code, userMessage, fieldErrors?) returns the failure branch with the error fields filled.
Here is the parse-failure branch of createInvoice 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 });The finished form of last lesson’s placeholder: that branch returned a hand-written { ok: false, error: { … } } literal; err() is the same shape with the 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 });Pulls the per-field messages out of the Zod failure in exactly the shape fieldErrors wants. Why flattenError and not treeifyError: next paragraph.
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; why that payload stays small comes later.
Zod projects a validation error two ways. z.flattenError(error) gives a flat { formErrors, fieldErrors } where fieldErrors is a Record<string, string[]>, exactly the type of Result’s fieldErrors, so it fits; z.treeifyError(error)’s nested tree does not. Pick the projection that matches the type your contract declares.
Throw at the framework edge, return everywhere else
Section titled “Throw at the framework edge, return everywhere else”Throw at the framework edge; return Result inside the action body, where the form branches on it.
You throw when:
- The resource is gone and a 404 is the right experience:
notFound(). - Navigation is the outcome, like sending the user to a new invoice’s page:
redirect(`/invoices/${id}`). Becauseredirect()raises to do its job, keep it outside anytry/catchthat would swallow it. - A programmer or infrastructure error has no in-form fix, like a downed database or a missing env var: let it propagate to
error.tsx. - The auth helper rejects a missing session: it throws or redirects, and the framework catches it.
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 on the suppression list ), unique-constraint conflicts. This is the common case for a mutation, since most failures are the user’s to fix.
One question collapses the decision: can the user fix this from where they are? If yes (they correct a field and retry, they pick a different slug), return a Result. If no, or if the right move is to leave the page, throw or use a framework convention.
Walk the tree to see the questions in order.
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.
A small, stable set of error codes
Section titled “A small, stable set of error codes”The code field is a cross-layer contract, and a contract with infinite possible values is no contract at all, so its values are a fixed set:
validation: the input failed the schema. Pair it withfieldErrors.conflict: a uniqueness or state collision, like a taken slug or a duplicate row.not_found: a referenced record is missing, returned as data instead of throwingnotFound().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, surfaced as data instead of thrown.
Two rules keep the set useful.
First, keep it small, roughly six to ten for the whole app. The temptation is to mint a code per action, invoice_slug_taken, customer_email_taken, org_name_taken; resist it. All three are conflict, separated by their userMessage. Codes exist for branching, and there are only a few branches worth having; the specifics live in userMessage. Add a domain code like plan_limit only when a real layer switches on it.
Second, get unauthorized versus forbidden right, because they map to different HTTP responses (401 versus 403) and newcomers swap them constantly. 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.
Pull the union out of the Result type and name it, so the err helper can reference it and the set has one home:
export type ErrorCode = | 'validation' | 'conflict' | 'not_found' | 'unauthorized' | 'forbidden' | 'rate_limited' | 'internal';Now every error.code is checked against the set: err('conflcit', …) is a compile error, not a silent failure that surfaces months later when the form’s code === 'conflict' branch never matches. A string-literal union gives this exhaustiveness with none of an enum’s runtime baggage.
Map known errors to codes; never leak the raw error
Section titled “Map known errors to codes; never leak the raw error”The database doesn’t return a Result; it throws. A colliding slug makes Postgres raise an error, and the action’s job is to catch the throws it recognizes, turn them into returned failures, and let the rest fly on toward error.tsx. The tempting shortcut, catching everything and stuffing the error’s message into userMessage, is a security mistake.
} catch (e) { return err('internal', (e as Error).message);}This ships your schema to the browser. A Postgres e.message carries constraint names, column names, and sometimes the offending values, handing an attacker a map of your database.
} catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'That slug is already taken.'); } throw e;}The user gets a clean message; the logs get the real error. Known failures become a typed code plus a written message; everything else re-throws.
The second variant is the pattern, and its name is 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, return its id. The try exists only so the next two branches can intercept what the insert throws.
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: map Postgres’s unique violation to the conflict code and a written message. isUniqueViolation is a small, abstractly named helper in /lib.
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 isn’t yours to translate, so let it reach error.tsx with its real message intact.
isUniqueViolation lives in /lib as a tested helper because detecting a Postgres unique violation is harder than it looks. That logic belongs to the database layer, in one place, so no action has to get it right alone.
The catch block is also where the two audiences split: the action writes a clean userMessage for the user, while your logging captures the full thrown error for you.
A quick gut-check. The catch below runs after a unique violation. Which return belongs on the blank?
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: the single owner of failure copy
Section titled “userMessage: the single owner of failure copy”Every Result failure carries a human-readable string, and the form renders it verbatim, inventing no fallback of its own.
Each message has one owner: the schema writes validation copy (carried in fieldErrors), the action writes business-rule copy (the userMessage on a conflict, a plan_limit, and so on), and the form writes nothing.
So when you catch the form reaching for a hardcoded “Something went wrong,” the action forgot its userMessage, and the fix belongs there.
On success, return the ID, not the row
Section titled “On success, return the ID, not the row”On ok, data is the minimal thing the caller needs: usually the new entity’s id, or null for a fire-and-forget mutation, never the full Drizzle row.
The Result is serialized over the network on every mutation, and a fat row costs you three ways: you ship timestamps and columns the client never reads; you weld the wire shape to the table shape, so a new column silently changes what every caller receives; and a raw Drizzle row carries prototype methods that can fail serialization outright.
Projecting to { id } sidesteps all three, and the client re-reads fresh data through the revalidated cache anyway.
Two channels, one rule: return the expected, throw the unexpected.
In the next chapter the form reads this same Result through useActionState , branching on state.ok to show userMessage as a banner and fieldErrors under each field.
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.