Skip to content
Chapter 8Lesson 1

Throw errors, return Results

A TypeScript discipline for failure handling that splits every error into a thrown Error or a returned Result, the foundation the rest of the course builds on.

Imagine writing parseInvoiceCsv(file), a function that parses an uploaded invoice CSV. Before any code, consider how it can fail. The file might be empty because the user picked the wrong export. A row might be malformed, with the wrong column count on line 17. A value might be out of range, like a 99,999,999.99 total on a system that caps invoices at six figures. Or the disk read might fail because the temp file vanished between upload and parse.

The reflex is to throw new Error(...) for all four, wrap the call in try/catch, and render “something went wrong.” Instead, ask one question of each failure: can the caller do something different in this case? That question splits the four into two channels and gives you a signature for parseInvoiceCsv.

Every failure in a JavaScript program travels on one of two channels. A throw bubbles a value up the call stack until something catches it or the runtime treats it as unhandled. A return passes a value down to the immediate caller; when that value carries a tagged failure, the type system makes the caller inspect it before reading either field.

Return the expected
Throw the unexpected
What it carries
A failure the caller is expected to handle as part of normal flow — validation, business-rule rejection, "not found" for an optional lookup, conflict, rate-limited.
An operational failure the caller cannot reasonably recover from inside its own logic — database down, request canceled, invariant violated, disk full.
Who handles it
The immediate caller branches on the discriminant and renders a different message, retries, or falls back. error.code
A framework boundary — error.tsx, the route handler's catch, the Server Action wrapper — decides on the user-visible response.
Canonical site
A discriminated-union return shape. Result<T, E>
A thrown Error (or a custom Error subclass). throw new Error(...)
Example
parseInvoiceCsv returns err({ code: 'OUT_OF_RANGE', column: 'total' }) for a row whose total exceeds the column's max — the caller highlights that column for the user.
parseInvoiceCsv throws when the disk read fails — no caller can do anything different about a temp file that vanished.
The two channels of failure, side by side.

The decision comes down to one question:

Can the caller reasonably do something different per case?

  • Yes, the caller branches on the cause. A domain failure: an expected outcome in the function’s contract, a discriminant the caller reads to render a different message, retry, or fall back. Return a Result<T, E>.
  • No, the caller can only log and show “something went wrong.” An operational failure: an infrastructure or unexpected fault with no per-case recovery at this layer. Throw, and let a framework boundary decide the user-visible response.

Run the question on parseInvoiceCsv’s four failures:

  • Empty file: the caller can render “this file has no invoices, please re-upload,” distinct from “row 7 is malformed.” Return { code: 'EMPTY' }.
  • Malformed row: the caller can highlight the specific row. Return { code: 'INVALID_ROW', row: 17 }.
  • Out-of-range column: the caller can highlight the specific column. Return { code: 'OUT_OF_RANGE', column: 'total' }.
  • Disk read failed: no caller has a recovery beyond “show the global error toast.” Throw.

Three are expected and travel on the return channel as a discriminated union; one is operational and throws.

try/catch/finally, in the strict-mode shape

Section titled “try/catch/finally, in the strict-mode shape”

The throw channel has three pieces. A try block guards a section of code; if anything inside throws, control jumps to the matching catch; and finally runs on the way out no matter what happened.

const closeStaleSessions = async (orgId: string) => {
const connection = await pool.acquire();
try {
const stale = await connection.query(/* ... */);
await connection.execute(/* ... */);
return stale.length;
} catch (err) {
// err is typed unknown — accessing err.message here is a compile error
log.error('closeStaleSessions failed', { orgId });
throw err;
} finally {
connection.release();
}
};

The guarded block. Anything between try { and its closing } is under the catch’s protection. A synchronous throw inside, or a rejection from any awaited Promise, jumps to catch. One exclusion: a throw from a nested async call you don’t await escapes as an unhandled rejection.

const closeStaleSessions = async (orgId: string) => {
const connection = await pool.acquire();
try {
const stale = await connection.query(/* ... */);
await connection.execute(/* ... */);
return stale.length;
} catch (err) {
// err is typed unknown — accessing err.message here is a compile error
log.error('closeStaleSessions failed', { orgId });
throw err;
} finally {
connection.release();
}
};

The catch parameter is unknown. TypeScript’s strict flag turns on useUnknownInCatchVariables, typing err as unknown, so the compiler refuses any property access, including err.message. JavaScript lets you throw any value, even 42, so unknown shape is the only safe assumption. The next lesson covers the instanceof Error narrow that unlocks err.message.

const closeStaleSessions = async (orgId: string) => {
const connection = await pool.acquire();
try {
const stale = await connection.query(/* ... */);
await connection.execute(/* ... */);
return stale.length;
} catch (err) {
// err is typed unknown — accessing err.message here is a compile error
log.error('closeStaleSessions failed', { orgId });
throw err;
} finally {
connection.release();
}
};

finally runs whatever happened. Whether the try completed normally, the catch ran, or the catch rethrew, finally runs before control leaves the function. Use it for cleanup, like releasing the connection above. One pitfall: a return or throw inside finally overrides the try/catch outcome, so keep finally to side effects, not control flow.

1 / 1

When the catch has nothing to do with the error, like a fire-and-forget log you’d rather drop than let crash the surrounding flow, omit the parameter:

try {
await logEvent(event);
} catch {
/* swallow — logging is fire-and-forget */
}

This bare catch is not an escape from the unknown typing; it’s only for when there’s nothing to read.

Most catches you write guard await expressions, not synchronous throws. The rule from the async chapter still holds: a rejected Promise becomes a throw at the await site, and the synchronous catch rules take over from there. That has three practical consequences.

try {
const invoice = await fetchInvoice(id);
return invoice;
} catch (err) {
// err is unknown — the rejection from fetchInvoice lands here
}

The Promise rejects, the await throws, the catch catches it. Treat a rejected Promise like any thrown value: err is unknown, and you narrow before reading.

All three follow from one rule: the await is where the throw happens. If it’s inside the try, the catch sees the rejection; if it’s missing or skipped by a bare return, the catch sees nothing and the rejection becomes unhandled .

JavaScript lets you throw any value: throw 'oh no', throw 42, and throw { code: 'BILLING' } are all legal. The rule for this course: the thrown value is always an Error instance, or a subclass of Error.

if (!total) throw 'missing total';
if (status === 'void') throw { code: 'VOID_INVOICE' };

A string or plain object is thrown. instanceof Error returns false, so the catch can’t read message, name, stack, or cause. There’s no stack trace either, so nothing tells you where the failure happened.

The payoff is a predictable catch: one instanceof Error narrows every thrown value to that known surface, and the trace points at the throw site, not the catch site. Some SDKs and browser APIs still throw strings or plain objects; the next lesson adds a small ensureError normalizer that wraps those before a catch reads them. Inside the course’s own code the rule is absolute: throw new Error(...), or a subclass that extends it, never anything else.

Now the return channel needs its shape. When a failure is expected and the caller branches on the cause, you return a discriminated union the type system forces the caller to inspect before reading either side. That is the Result<T, E> shape: the discriminated union from the earlier TypeScript chapter, now applied to async returns. You have the structural tool already; this lesson teaches when to reach for it.

lib/result.ts
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

The shape. A discriminated union with two variants. The boolean ok is the discriminant: value lives on the success variant, error on the failure variant, each carrying only the field valid for its state. No Result has both value and error, and none has neither. The impossible state is unrepresentable.

lib/result.ts
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

The success factory. ok(value) builds the success variant. Its return type is Result<T, never>; the never on the absent side lets the value land in any Result<T, E> slot, whatever E is.

lib/result.ts
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

The failure factory. err(error) mirrors it, returning Result<never, E> so it lands in any Result<T, E> slot whatever T is. Together ok and err are the only two ways the project builds a Result; no one writes the { ok: true, value } literal inline.

1 / 1

T is whatever the success carries: an array of invoices, a user record, a token. E is where the real design work happens.

The point of Result isn’t to return an error string but a structured, tagged failure the type system makes the caller inspect. So E is itself a discriminated union, each tag carrying exactly the data its branch needs to render a different message or retry differently.

Here is the parseInvoiceCsv signature:

type ParseError =
| { code: 'EMPTY' }
| { code: 'INVALID_ROW'; row: number }
| { code: 'OUT_OF_RANGE'; column: string };
const parseInvoiceCsv = async (
file: File,
): Promise<Result<Invoice[], ParseError>> => {
// ... can throw on disk read errors; returns ok(invoices) or err({ code: '...' })
};

Read each side of the Promise<Result<Invoice[], ParseError>>. Success returns an array of invoices. Expected failure returns one of three tagged variants, each carrying what its branch needs: the row number for INVALID_ROW, the column name for OUT_OF_RANGE. The disk-read failure is absent from ParseError: no caller can branch on it usefully, so it travels on the throw channel instead.

The caller, then, looks like this:

const result = await parseInvoiceCsv(file);
if (result.ok) {
return showInvoices(result.value);
}
switch (result.error.code) {
case 'EMPTY':
return showEmptyMessage();
case 'INVALID_ROW':
return highlightRow(result.error.row);
case 'OUT_OF_RANGE':
return highlightColumn(result.error.column);
default:
return assertNever(result.error);
}

Two things to notice. First, if (result.ok) is the discriminant narrow: inside the if, result.value is Invoice[]; outside it, result.error is ParseError, and the compiler refuses result.value. Second, the default branch calls assertNever(result.error), the exhaustiveness helper: add a fourth variant to ParseError and every consumer’s switch becomes a compile error until it handles the new case.

That is the payoff. The type system forces the caller to check ok before reading either field, branch on the discriminant, and update when a new failure mode is added. None of it holds if all four failures throw.

The opposite mistake is reaching for Result<T, E> on every failure. A signature like Result<T, 'DATABASE_DOWN' | 'TIMEOUT' | 'INTERNAL'> does operator-error reporting in the return channel. What does the caller do with error.code === 'DATABASE_DOWN'? Log it and render “something went wrong,” same as TIMEOUT and INTERNAL. That isn’t per-case branching; it’s a throw forced into a return.

A failure belongs on the return channel when it is expected and the caller branches on it. If either condition is missing, it’s a throw.

The channel decision tells you whether a failure throws or returns. A second question is where the catch goes. There are two right places and one anti-pattern.

At the framework boundary, for unexpected throws. Server Actions, route handlers, page-level loaders, and React’s error.tsx boundary catch the throws that escaped business code. The audit log and the split between user-facing and operator-facing messages happen there. The boundary is a destination, not a mechanic you reach for in business code.

At a call site with a non-throw alternative. A try/catch wraps a third-party SDK call to convert the channel: the vendor’s throw becomes the project’s Result.err(...).

The anti-pattern alongside the conversion:

try {
await chargeInvoice(invoiceId);
} catch (err) {
// logs and continues — the caller still thinks the charge succeeded
log.error('charge failed', { invoiceId, err });
}
return ok(invoice);

The catch logs and continues with no remediation. The function returns ok(invoice) even though the charge failed, so the caller never finds out, the user sees a success page, and the invoice stays unpaid. The catch neither converts the channel nor lets the boundary handle it. Either the failure is recoverable, so the catch should convert it to Result.err, or it isn’t, so the catch shouldn’t exist and the boundary catches the throw.

The right shape has a catch because the catch is doing something: converting a vendor’s throw into the project’s Result.err. The anti-pattern has a catch because the developer wanted to “handle the error somehow,” and that vagueness is the tell. A catch that neither converts the channel nor defers to the boundary shouldn’t exist.

The first exercise drills the return-vs-throw decision, the second has you write the Result<T, E> shape from a throwing function.

Eight failures from a real codebase. Sort each into the channel an experienced engineer would choose.

Sort each failure into the channel an experienced engineer would route it through. Apply the 'can the caller do something different per case?' heuristic. Drag each item into the bucket it belongs to, then press Check.

Return Result<T, E> The caller branches on the cause
Throw — let the boundary catch The caller cannot reasonably recover per-case
CSV row out of range during invoice import
Postgres connection refused
Stripe API key rotated mid-request
User submitted a duplicate email at sign-up
S3 returned 503 on upload
Zod parse failed on form input
Invariant violated: tenant ID mismatch between session and resource
OAuth provider returned an unknown error code

Exercise 2: Refactor throws to Result<T, E>

Section titled “Exercise 2: Refactor throws to Result<T, E>”

Refactor the throwing parseUser to return Result<User, ParseError>, carrying the offending value on INVALID_AGE so the caller can render “we got 'thirty', please enter a number.”

Refactor parseUser so it returns Result<User, ParseError> for the two validation failures. ParseError is a discriminated union with codes 'INVALID_EMAIL' and 'INVALID_AGE'. The INVALID_AGE variant carries the offending value as 'received'.

    Reveal solution
    const parseUser = (input) => {
    if (!input.email.includes('@')) return err({ code: 'INVALID_EMAIL' });
    if (typeof input.age !== 'number') {
    return err({ code: 'INVALID_AGE', received: input.age });
    }
    return ok({ id: input.id, email: input.email, age: input.age });
    };

    Both validation failures travel on the return channel now. INVALID_AGE carries received so the caller can render “we got 'thirty', please enter a number.” The caller would narrow with if (result.ok), then switch on result.error.code.

    The catch still types err as unknown, so err.message is a compile error. The next lesson introduces the instanceof Error narrow that unlocks it, then builds custom Error subclasses for domain failures.