Result Types: Errors as Values

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

A Result type turns expected failure into a typed return value: Result<T, E> is either success data T or error data E. Use it when callers are supposed to handle the failure, not when the program has broken its own rules.

14.2 used this shape for decoded network data. Now you make it explicit, chain it through several fallible steps, and learn when not to use it.

Result<T, E> is a discriminated union

A Result is a value that represents success or failure without throwing. In this course, it is a discriminated union, an object union whose ok field tells the checker which shape you have:

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

→ joined: ada@example.com on pro
→ blocked: Plan must be free or pro

The ok check is the whole move. Before the check, the value might be either shape. After if (signup.ok), the checker knows data exists. In the else, it knows only error exists.

That is why Result is clearer than returning Signup | undefined. The failure carries a reason your caller can show, log, or branch on.

Chain fallible steps

Railway-oriented code is code where each step either keeps the value on the success track or switches to the error track; once it is on the error track, later success-only steps are skipped.

You do not need a library to learn the pattern. Two helper functions are enough:

  • andThen chains a synchronous step that may fail.
  • andThenAsync chains an async step that may fail.
typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ ticket for Ada: r-A-12
→ could not reserve testing
→ Card was declined
→ charge attempts: 2

The sold-out purchase never reaches chargeCard, so chargeAttempts is 2, not 3. The error track short-circuits the rest of the success-only work.

For async code, the common shape is Promise<Result<T, E>>: the promise says when the operation finishes, and the Result says whether the expected operation succeeded.

Exceptions vs results

An expected failure is a failure the caller can reasonably handle: sold out, bad coupon, not found, validation failed. An unexpected failure is a bug, broken invariant, or corrupted state: a negative stored byte count, an impossible switch branch, a receipt generator returning an empty id. An exception is a thrown value or rejected promise used for that exceptional path.

Here is that decision rule in code:

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

→ choose another seat: B9
→ unexpected: Stored seat row invariant broken

B9 not existing is normal product behavior, so it returns an error value. A stored seat with row 0 means your own data is impossible, so the function throws.

Where the ecosystem landed

You will see three practical styles in TypeScript codebases:

  • Plain discriminated unions, like the one in this lesson. They are transparent, dependency-free, and enough for many app cores.
  • neverthrow, a library centered on Result-style values and helpers for sync and async flows.
  • Effect, a larger library where computations carry typed success and error channels alongside more runtime machinery.

Do not add a dependency just to avoid writing this five-line union. Reach for a library when your codebase already uses one, when chaining gets repetitive across many modules, or when the team wants a shared vocabulary for retries, recovery, and async composition.

Section 15 turns from expected async failures to defensive code around exceptions, nullability, and impossible states. The same habit carries forward: model what callers can handle, and make impossible states loud.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion