Two channels: throw the unexpected, return the expected
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 you are writing a function that parses uploaded invoice CSVs: parseInvoiceCsv(file). Before you write any code, consider how it can fail. There are at least four ways. The file might be empty because the user uploaded the wrong export. A row might be malformed, with a column count that doesn’t match on line 17. A column might be out of range, such as a total of 99,999,999.99 on a system that caps invoice totals at six figures. And the disk read itself might fail because the temp file vanished between upload and parse.
A junior writes throw new Error(...) for all four, the caller wraps the whole thing in try/catch and renders “something went wrong,” and that’s the end of it. An experienced engineer reads those four failures and asks one question: can the caller do something different per case? That question splits the four into two channels. By the end of this lesson you’ll have a signature for parseInvoiceCsv and a reading habit you can apply to every failure you meet from here on.
This chapter treats the error channel as a design decision, not a try/catch reflex you reach for once something has already gone wrong. This lesson teaches the channel decision and the minimum mechanics to use either channel. The next lesson, on narrowing the catch and authoring domain errors, covers how to read errors safely inside the catch: instanceof narrowing, custom error classes, and Error.cause chains. Together the two lessons are the language-level foundation every backend chapter from here forward builds on.
The two channels
Section titled “The two channels”Every failure in a JavaScript program travels on one of two channels. A throw bubbles a value up the call stack, past every function that doesn’t catch it, until something catches it or the runtime treats it as unhandled. A return passes a value down to the immediate caller through normal control flow, and when that return value carries a tagged failure, the type system makes the caller inspect it before reading either field.
The two channels carry different kinds of failures, are handled in different places, and have different canonical shapes. Here they are side by side.
The whole lesson hinges on one question:
Can the caller reasonably do something different per case?
That is the entire heuristic, and it has a yes and a no answer, each with its own consequence.
- Yes, the caller branches on the cause. The cause is part of the function’s contract. The caller reads a discriminant, such as
error.code === 'INVALID_ROW'versuserror.code === 'OUT_OF_RANGE', and renders a different message, retries with different inputs, or falls back to a different path. Return aResult<T, E>. - No, the caller would log and rethrow or display “something went wrong.” The cause is operational, not domain. There’s nothing useful for the caller to do at this layer. Throw, and let a framework boundary decide the user-visible response.
Now run the question on each of parseInvoiceCsv’s four failures:
- Empty file: the caller can render “this file has no invoices, please re-upload,” which is distinct from “row 7 is malformed.” Return
{ code: 'EMPTY' }. - Malformed row: the caller can highlight the specific row to the user. 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 of
parseInvoiceCsvhas a recovery different from “show the global error toast.” Throw.
Three of those travel on the return channel as a discriminated union, and one travels on the throw channel. That’s the split the rest of the lesson teaches you to write.
try/catch/finally, in the strict-mode shape
Section titled “try/catch/finally, in the strict-mode shape”Before the return channel gets its shape, the throw channel needs its mechanics. The three pieces are try, catch, and finally. A try block guards a section of code. If anything inside throws, control jumps to the matching catch. The finally block runs whatever happened, whether that was a normal exit, a caught throw, or a rethrow.
Here is a small canonical example, walked through one step at a time.
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 the closing } is under the catch’s protection. A synchronous throw inside, or a rejection from any awaited Promise, jumps execution to catch. There is one important exclusion: throws from nested async calls that aren’t awaited do not land here. They escape the try block as unhandled rejections.
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. Under TypeScript’s strict flag, which switches on useUnknownInCatchVariables by default, err is typed unknown. The compiler refuses err.message, err.name, err.stack, any property access at all. JavaScript lets you throw any value, including a string, a plain object, or 42, so the only safe assumption is that err is some value of unknown shape. The next lesson of this chapter introduces the narrow, instanceof Error, that unlocks err.message. For now, the rule is that the catch type is unknown and the compiler enforces it.
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 block completed normally, the catch ran, or the catch rethrew, finally runs before control leaves the function. Its main use is resource cleanup: release a connection, abort a controller on shutdown, increment a counter. Watch out for one pitfall: a return or throw from inside finally overrides the try/catch outcome, so use finally for side effects, not control flow.
There’s one shorthand worth knowing. When the catch genuinely doesn’t read the error, say a fire-and-forget log that you’d rather drop than let crash the surrounding flow, you can write catch with no parameter at all:
try { await logEvent(event);} catch { /* swallow — logging is fire-and-forget */}This is the bare catch shape. Use it when the catch isn’t going to read the error because there’s nothing to do with it. It is not a general escape from the unknown typing. If you need to inspect the error, the next lesson’s narrowing pattern applies.
The async-throw flow
Section titled “The async-throw flow”Most of the catches you write will guard await expressions, not synchronous throws. The previous chapter, on async semantics, laid the groundwork: a Promise rejects, and the await site is where the rejection lands. The takeaway is that a rejected Promise becomes a throw at the await site, and the synchronous catch rules then apply.
That single fact has three practical consequences. Click through them.
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. This is just the synchronous mechanic applied through await. Treat a rejected Promise like any thrown value: the catch runs, err is typed unknown, and you narrow before reading.
try { fetchInvoice(id); // missing await — the rejection escapes the try} catch (err) { // never runs — the catch sees nothing}No await, no catch. Without await, the function returns the Promise immediately, so the try block has already exited by the time the Promise rejects. The rejection becomes an unhandled rejection: the process logs it, and modern Node terminates non-zero by default. The fix is the discipline from the previous chapter’s parallel-by-default lesson, void fetchInvoice(id).catch((err) => log.error(err)), for fire-and-forget calls.
// wrong — `return getInvoice(id)` returns the promise to the caller;// the catch never sees the rejectiontry { return getInvoice(id);} catch (err) { // never runs}
// right — `return await` keeps the function on the stack until the// promise settlestry { return await getInvoice(id);} catch (err) { // catches rejection}Inside a try, the function must stay on the stack to catch the rejection. Without await, the function pops off the call stack the moment it returns the Promise, so by the time the Promise rejects there’s no try frame left to catch it. This is what the return await discipline from the previous chapter is for: inside try, always return await. Outside try, the bare return is fine.
The three consequences are all variations on one rule: the await is where the throw happens. If the await is inside the try, the catch sees the rejection. If it’s missing, or if return skips it, the catch sees nothing and the rejection becomes an unhandled rejection .
The “only throw Error” rule
Section titled “The “only throw Error” rule”JavaScript lets you throw any value. You can write throw 'oh no', throw 42, or throw { code: 'BILLING' }. All three are syntactically legal, and all three cause problems this course never reaches for.
The rule is simple: the thrown value is always an Error instance, or a subclass of Error. Here is the wrong shape next to the right one.
if (!total) throw 'missing total';if (status === 'void') throw { code: 'VOID_INVOICE' };A string or a plain object is thrown. Every catch site that touches this code has to type-check the value before doing anything with it. Since instanceof Error returns false, the catch can’t rely on message, name, stack, or any of the surface that Error provides. There’s no stack trace either, so the trace alone won’t tell you where the failure happened.
if (!total) throw new Error('missing total');// for domain failures with structure, reach for a custom Error subclass// (covered in the next lesson)An Error instance is thrown. The catch can narrow with instanceof Error and read message, name, stack, and cause. The stack trace points at the throw site. Every catch across the codebase can rely on the same surface.
There are three reasons for it.
- Predictable catch shape. Once you know everything thrown is an
Error,instanceof Errornarrowserrto a known surface:message,name,stack, andcause. Throwing a string forces every catch site to do a type check before reading anything. - Stack traces. Only
Errorinstances carry astackproperty. The runtime captures the trace at the moment theErroris constructed. Throw a plain object and that trace doesn’t exist, so the operator log shows you the catch site but not the throw site. - A safety net for third-party seams. Some legacy SDKs and browser APIs do throw strings or plain objects. The next lesson introduces a small
ensureErrornormalizer for exactly that: it wraps any unknown thrown value in anErrorbefore the catch reads it. Inside the course’s own code, though, the rule is absolute:throw new Error(...)or a custom subclass, never anything else.
When you start authoring domain-specific error classes such as BillingError, RateLimitError, and TenancyError, they all extend Error and the rule still holds. The next lesson walks through the shape.
The Result<T, E> shape
Section titled “The Result<T, E> shape”The throw channel has its mechanics. Now the return channel needs its shape. When a failure is expected, meaning the caller is going to branch on the cause, you return a discriminated union that the type system forces the caller to inspect before reading either side. That’s the Result<T, E> shape.
This is the discriminated-union shape from the earlier chapter on TypeScript bug-class moves, now applied to async returns. You already have the structural tool. This lesson teaches when to reach for it.
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 variant carries only the field that’s valid for its state. There’s no Result where both value and error are populated, and none where both are undefined. This is the impossible-state principle from the earlier chapter on TypeScript bug-class moves, applied to function returns.
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>, and the never on the absent side is what lets this value land in any Result<T, E> slot regardless of what E is. The caller’s discriminant check on ok === true peels off this variant.
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) is the mirror image. Its return type is Result<never, E>, where the never on the absent side lets it land in any Result<T, E> slot regardless of T. Together, ok and err are the only two ways the project constructs a Result. No one writes the literal { ok: true, value } shape inline, since both factories enforce the discriminant.
The T is whatever the success carries, such as an array of invoices, a user record, or a token. The E is where the real design work happens.
E is a discriminated union too
Section titled “E is a discriminated union too”The point of Result isn’t to return an error string. It’s to return a structured, tagged failure that the type system makes the caller inspect. So E is itself a discriminated union, where each tag carries exactly the data the caller needs to render a different message or retry differently.
Here’s the parseInvoiceCsv signature the lesson has been heading toward:
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 what’s on each side of the Promise<Result<Invoice[], ParseError>>. The success channel returns an array of invoices. The expected-failure channel returns one of three tagged variants, each carrying the data its branch will need: the row number for INVALID_ROW, the column name for OUT_OF_RANGE. The disk-read failure is absent from ParseError. That failure travels on the throw channel, because no caller can branch on it usefully, so it’s not in the contract. Three expected failures live in E, and one operational failure stays in the throws.
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, the if (result.ok) check is the discriminant narrow. TypeScript knows that inside the if, result.value is Invoice[], and in the rest of the function, result.error is ParseError. The compiler refuses result.value access outside the if. Second, the default branch calls assertNever(result.error), the exhaustiveness helper from the earlier chapter on TypeScript bug-class moves. It lives in the project’s shared utilities and is imported at the call site. The moment you add a fourth variant to ParseError, every consumer’s switch becomes a compile error until you handle the new case. The compiler holds the contract.
That’s the structural payoff Result<T, E> was always heading toward. The type system enforces three things: the caller checks ok before reading either field, the caller branches on the discriminant, and adding a new failure mode breaks every caller that hasn’t updated. None of that is possible if all four failures throw.
The overuse trap
Section titled “The overuse trap”There’s a matching warning in the other direction. Reaching for Result<T, E> on every failure is overuse, just as throwing for every failure is. A function whose signature is Result<T, 'DATABASE_DOWN' | 'TIMEOUT' | 'INTERNAL'> is doing operator-error reporting in the return channel. What does the caller do with error.code === 'DATABASE_DOWN'? Log it and render “something went wrong,” and the same goes for TIMEOUT and INTERNAL. That’s not per-case branching. It’s a throw the caller is being forced to handle as a return.
To restate the rule: 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.
Where the catch lives
Section titled “Where the catch lives”The channel decision tells you whether a failure throws or returns. A companion question tells you where the catch lives. 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 the user-facing message and the operator-facing one happen there. A later unit on error discipline covers this in depth. For this lesson, just know that the boundary is the destination, not a mechanic you reach for in business code.
At a call site that has a non-throw alternative. This is a try/catch that wraps a third-party SDK call to translate a vendor-shaped throw into a domain Result.err(...). The catch exists to convert the channel: the vendor’s throw becomes the project’s return.
Those are the two places a catch belongs. Here is the anti-pattern:
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. The caller never finds out, the user sees a success page, and the invoice stays unpaid. This is a shape that should fail code review: the catch neither converts the channel nor lets the boundary handle it. Either the failure is recoverable, in which case the catch should convert to Result.err, or it isn’t, in which case the catch shouldn’t exist and the boundary should catch it.
try { await chargeInvoice(invoiceId); return ok(invoice);} catch (e) { // catch parameter is `e` to avoid shadowing the `err` factory helper // vendor's throw becomes our Result.err — the caller can branch on // 'CARD_DECLINED' if (e instanceof Stripe.errors.StripeCardError) { return err({ code: 'CARD_DECLINED', userMessage: 'Your card was declined.', }); } throw e; // operational — let the boundary catch it}The catch converts the channel for the case the caller can act on. A StripeCardError is something the form can render as a per-card error message, so the function returns err({ code: 'CARD_DECLINED', ... }). Anything else is operational: Stripe’s API is down, the key was rotated, or the request was malformed. The catch rethrows those so the boundary owns the operational response. (The instanceof narrow is the next lesson’s territory; here it just shows the shape of the conversion.)
Read those two tabs back to back. 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. If the catch isn’t doing one of the two right things, it shouldn’t exist.
Practice
Section titled “Practice”There are two exercises. The first checks the channel decision, which is the heart of the lesson. The second checks that you can write the Result<T, E> shape from a function that currently throws.
Exercise 1: Route each failure
Section titled “Exercise 1: Route each failure”Below are eight failures from a realistic 2026 SaaS codebase. For each, decide whether a senior would route it through Result<T, E> or let it throw and bubble to the framework boundary. Apply the heuristic: can the caller reasonably do something different per case?
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.
Exercise 2: Refactor throws to Result<T, E>
Section titled “Exercise 2: Refactor throws to Result<T, E>”You’re given a parseUser function that throws for every validation error. Refactor it so it returns Result<User, ParseError>, where ParseError is a discriminated union of two tagged variants, INVALID_EMAIL and INVALID_AGE. The INVALID_AGE variant should carry the offending value 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.
Wrapping up
Section titled “Wrapping up”Every failure travels on one of two channels: return the expected, throw the unexpected. The reading habit is one question, can the caller reasonably do something different per case?, and the answer routes the failure. When it is expected and the caller branches, return a Result<T, E> whose E is a discriminated union of tagged variants. When it is unexpected, or there’s no per-case branch, throw an Error and let a framework boundary decide the user-visible response.
The catch still says unknown, so err.message is a compile error. The next lesson introduces the narrow that unlocks err.message, walks through small custom Error subclasses with literal-typed name discriminants, and shows how Error.cause chains carry the original failure forward when the catch rewraps it.
External resources
Section titled “External resources”The canonical reference for the mechanics. Reach here when you need syntax depth this lesson didn't cover: the spec-level rules for finally, nested try blocks, and the bare catch shape.
The strict-mode flag that types the catch parameter as unknown. The TSConfig reference page with the example that mirrors the lesson's compile-error walkthrough.