Error Handling Patterns

Advanced · 20 min read · ▶ live playground · ✦ checkpoint

Error handling in TypeScript is two skills at once: model expected failures as typed values, and make unexpected failures loud without losing their details. This lesson is about the second half: custom Error classes, cause chains, safe catch narrowing, domain error unions, and logs that do not smuggle untyped junk into production.

Section 14 gave you the decision rule: expected failures return Result<T, E>, while broken assumptions throw. Now you make thrown errors useful instead of mysterious.

Custom errors carry cause chains

A custom error class is a runtime constructor plus a checked shape. You use it when callers need to distinguish one unexpected failure from another with instanceof, or when the error needs structured fields like a lessonId.

The ES2022 Error constructor accepts a cause, which lets one layer wrap a lower-level failure without deleting it. Treat catch (error) as unknown, narrow it, then read properties.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ LessonLoadError for error-handling-patterns
→ Could not load lesson error-handling-patterns
→ caused by ConfigError: API key is missing

The commented line fails because a caught value is unknown under the course settings. That is not TypeScript being picky. JavaScript can throw an Error, a string, a number, or a strange object from a library. instanceof LessonLoadError proves both facts you need: this is an Error, and it has the lessonId field your class created.

The cause chain preserves the original failure. Without it, LessonLoadError would say only "could not load lesson"; with it, your logs can still tell you the configuration problem that started the chain.

The prototype gotcha is about runtime identity

This playground targets ES2022, so normal class X extends Error works. new LessonLoadError(...) instanceof LessonLoadError is true here.

The old gotcha appears when code is compiled for older JavaScript targets, or when a hand-rolled subclass damages the prototype chain. instanceof is a runtime prototype check. If the object does not point at your subclass prototype, the check fails even though the object has the right fields.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ good instanceof GoodError: true
→ broken instanceof BrokenPrototypeError: false
→ broken instanceof Error: true

Shown as reference code for older targets, the repair looks like this:

class LegacyFriendlyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "LegacyFriendlyError";
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

Do not confuse this with TypeScript's structural typing. A plain object can satisfy many object types, but it cannot satisfy instanceof LegacyFriendlyError unless its runtime prototype chain says so.

Domain failures stay as data

Not every failure deserves a thrown error class. A missing @ in an email, a sold-out plan, or a declined card is normal product behavior. Callers can handle it, so keep it as a discriminated union and return it through Result<T, E>.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ created: ada@example.com on pro
→ blocked: Email must include @
→ blocked: enterprise is not open

Notice the split. createAccount does not throw for user-correctable input. It returns a typed failure value with a kind tag. The UI can branch, show a message, focus a field, or retry. Save thrown errors for states the caller cannot treat as a normal choice.

Log errors with typed context

Logging is another boundary. A logger that accepts Record<string, unknown> will swallow anything, including huge objects, secrets, functions, and circular data. A small context type keeps logs boring: ids, counts, booleans, and maybe null.

The error itself still starts as unknown, because a catch block can receive anything. Summarize the useful cases first, then attach typed context.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ [error] checkout failed
→ context requestId=req-42 userId=u-7 retryable=true attempt=2
→ error Error: charge service offline
→ cause Error: ECONNRESET

The break-it line fails because metadata is an object, not a LogValue. That pressure is useful. If your logs need richer data, design that shape intentionally instead of letting every call site invent its own payload.

Next lesson tightens the impossible-path side of this story: never turns discriminated unions into a checklist the compiler can enforce when a new variant appears.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion