Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~21 min· topic 5 of 6

Topic 13.5

Typed Error Handling

In one line

TypeScript can't say what a function throws, so errors need a plan: subclasses of Error you can tell apart with instanceof, a Result<T, E> union that makes failure part of the return type, cause to keep the original error, and using to clean up reliably.

Think of it like this

A postal service has two ways to say "I couldn't deliver". One is a loud siren: someone runs through the building shouting, and whoever is standing nearest has to deal with it (that is throw). The other is a note in the parcel slot: "delivered" or "failed, here's why" (that is a returned value). The siren interrupts everything and nothing on the parcel says it might sound. The note is part of the parcel, so you can't miss it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

throw
Stop normal flow and jump to the nearest catch. Any value can be thrown.
Error subclass
Your own class extending Error, so one kind of failure has its own name and fields.
Result<T, E>
A union: either success with a value or failure with an error. The caller must check which.
Discriminated union
A union where one shared field (like ok) tells you which member you have.
cause
An option on Error that keeps the original error inside a new one.
finally
A block that runs after try and catch, whether or not there was an error.
using
A declaration that calls the value's Symbol.dispose when the block ends.
Symbol.dispose
A method name the language knows. using calls it for cleanup.
Recoverable error
A failure the caller can sensibly handle, like bad user input.

Step by step

01Throwing is invisible in the type

find below can throw, and nothing in (id: number) => string says so. A caller can forget the try.

index.tswhole filets
function find(id: number): string {
  if (id !== 1) throw new Error("no such user");
  return "Asha";
}

02Name each failure with a subclass

A class per kind of failure lets catch choose. Give it fields the handler needs, and set name.

index.tswhole filets
class NotFoundError extends Error {
  constructor(readonly what: string) {
    super(what + " not found");
    this.name = "NotFoundError";
  }
}

03Or return the failure

With a Result, the failure is in the return type. The caller checks ok and TypeScript narrows to the right side.

index.tswhole filets
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

const r = parseAge("abc");
if (r.ok) {
  r.value;   // number
} else {
  r.error;   // string
}
Or return the failurediagram
Rendering diagram…

04Wrap with cause

When you catch a low-level error and throw a higher-level one, pass the original as cause. The chain stays intact.

index.tswhole filets
try {
  JSON.parse(text);
} catch (err) {
  throw new Error("Invalid config file", { cause: err });
}

05Clean up with using

using ties cleanup to the block. When the block ends by returning, falling off the end or throwing, Symbol.dispose runs. Several using in one block are disposed last first.

Clean up with usingdiagram
Rendering diagram…

Try it yourself

  1. 1

    Add a new failure

    In the first example add class ForbiddenError extends AppError with code 403 and throw it for id === 3. Which branch handles it? (None, so it is rethrown and the Console shows an uncaught error.) Add a branch for it.

  2. 2

    Peek at a failed result

    In the second example, move console.log(r.value) before the if. Read the error. Then make Result generic over the error with a default: type Result<T, E = string> and use Result<number>.

  3. 3

    Throw in the middle

    In the using example, move the line if (fail) throw new Error("boom"); to just before using b = open("b.txt");. Predict which files are closed when work(true) runs: only a.txt, because b.txt was never opened.

Code & diagrams

Error subclasses and instanceof

NotFoundError is an AppError and an Error. The else branch rethrows anything unexpected, so real bugs are not hidden.

Console

(nothing was printed)

Expected console output

Asha
NotFoundError 404 user 2 not found
true
Result: failure in the return type

Inside the if, r.value exists. In the else, r.error does. Try printing r.value before the check and read the error.

Console

(nothing was printed)

Expected console output

age is 43
problem: not a number: abc
problem: out of range: 200
cause keeps the original error

cause is typed unknown, because anything can be thrown. The original SyntaxError is still reachable once you check it.

Console

(nothing was printed)

Expected console output

Invalid config file
true
using: cleanup that always runs TypeScript 5.2+

Both files are closed, last opened first, on success and when the function throws. If this example fails to run in your browser, it is too old for explicit resource management.

Console

(nothing was printed)

Expected console output

open a.txt
open b.txt
working with a.txt b.txt
close b.txt
close a.txt
open a.txt
open b.txt
working with a.txt b.txt
close b.txt
close a.txt
caught boom

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use .value without checking ok

In the second example, replace the if (r.ok) { ... } else { ... } block with console.log(r.value);.

terminal
$ Types panel
── what you'll see ──
index.ts(13,17): error TS2339: Property 'value' does not exist on type 'Result<number, string>'.
Property 'value' does not exist on type '{ ok: false; error: string; }'.

Myth vs fact

Myth

TypeScript tells me which errors a function can throw.

Fact

It doesn't. There is no throws in the type system. The : never return type marks a function that never returns normally, which is different.

Myth

Result replaces exceptions completely.

Fact

You still need exceptions for bugs and for libraries that throw. A Result is for expected failures.

Myth

using is only for files.

Fact

It works for anything with Symbol.dispose: locks, database transactions, timers, temporary event listeners.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Subclassing built-ins relies on the class target. With target: es5, class X extends Error produced objects that failed instanceof X; with ES2015+ targets it works. TypeScript 7 no longer accepts es5, so the old workaround (Object.setPrototypeOf(this, X.prototype)) is not needed.

  • ▸

    A Result costs an allocation per call and adds ceremony in chains. Libraries like neverthrow add map and andThen helpers; many teams just write if (!r.ok) return r by hand. Keep the error type a union of string-literal codes or a discriminated union so callers can switch exhaustively (Topic 4.6).

  • ▸

    using desugars to try/finally with a stack of disposables, and a disposal that throws while another error is in flight produces a SuppressedError holding both. TypeScript types using variables as read-only and requires the value to have [Symbol.dispose]() or be null/undefined.

Remember this

  1. 1

    The problem this solves: when something fails, the function's type says nothing about it, so callers forget to handle the failure or handle the wrong one. The patterns here put failures back where the compiler and the reader can see them. In JavaScript, throw can throw anything, and a function's type has no throws part. Look at function parse(text: string): number: it may throw, it may not, and the type doesn't say. That's why catch (err) is unknown (Topic 13.3): the compiler honestly knows nothing. Error handling in TypeScript is therefore about conventions that put the information back.

  2. 2

    The first convention is **custom Error subclasses**: class NotFoundError extends Error. Each kind of failure becomes its own class, so a catch can tell them apart with instanceof (Topic 4.4), and each class can carry typed extra fields such as a status code. Set this.name so stack traces and logs show the right name. Two details: instanceof works for subclasses of Error when the code targets modern JavaScript (the old es5 target broke it; es5 is no longer accepted as a target in 7.0), and you should rethrow errors you don't recognise instead of swallowing them (else throw err).

  3. 3

    The second convention is the **Result<T, E> type**: a function doesn't throw for expected failures, it returns a union of success and failure: { ok: true; value: T } | { ok: false; error: E }. This is a discriminated union (Topic 4.5) with ok as the tag. The caller *can't* read .value until it has checked .ok, because on the failure side value does not exist. The compiler now makes the caller handle failure: forgetting is a type error. Rust makes this its standard (its Result type), and Go returns an error value next to the result; in TypeScript you choose it.

  4. 4

    When to use which? Use a Result for expected, recoverable failures the caller should decide about: invalid input, "user not found", a 404. Use throw for bugs and unexpected situations nobody can sensibly handle at that spot: broken invariants, out of memory, a failure deep inside a library. A good rule: if the caller would write try/catch around every call, return a Result. If failure means the program is wrong, throw.

  5. 5

    **cause** (an option on new Error(message, { cause })) is a standard way to wrap a low-level error in a higher-level one without losing it: "Could not load config" with the original SyntaxError underneath. TypeScript types it as unknown (like every thrown value), so check it before use. Wrapping gives users a clear message and developers the full chain.

  6. 6

    Cleanup is the third error problem: a file, lock or connection must be closed whether the code succeeded or threw. The old pattern is try { ... } finally { close() }. TypeScript 5.2 added **using**, from the explicit resource management feature of JavaScript: using file = openFile() calls file[Symbol.dispose]() automatically when the block ends, even if an error is thrown, and disposes in reverse order of creation. await using is the same with Symbol.asyncDispose. It is not available everywhere yet: check the MDN compatibility table for your browsers and Node version, and older runtimes need a polyfill for Symbol.dispose.

Explain it without notes

01

Why can't TypeScript type what a function throws, and what do teams do about it?

02

When would you return a Result instead of throwing?

03

What does cause give you, and what is its type?

04

What does using do, and how is it different from try/finally?

Practice

01

Define type Result<T, E> and write safeJsonParse(text: string): Result<unknown, string> that never throws.

02

Write a class Timer with a [Symbol.dispose] that logs stopped, and use it with using in a function that throws.

Trade-offs

  • ↔

    Exceptions keep the happy path clean but hide failure from the types. Result makes failure visible but every caller must handle or pass it on.

  • ↔

    Subclasses give precise instanceof checks, but a large hierarchy of error classes is hard to maintain. A code string or a discriminated union is often enough.

Done when you can

  • I can write an Error subclass and handle it with instanceof.

  • I can write and use a Result<T, E> and explain why .value needs the check.

  • I can decide between throwing and returning a Result.

  • I can wrap an error with cause and read it safely.

  • I can use using and say in which order resources are disposed.