Error Handling with Types
Model errors as values with Result<T, E> for explicit, composable error handling.
Traditional error handling in JavaScript relies on throw and catch. But exceptions have a fatal flaw: they're invisible in types. A function's signature doesn't tell you what errors it might throw, so you either handle every possible exception defensively (slow, verbose) or forget to handle them at all (bugs).
TypeScript inherits this problem. But there's a better way, borrowed from functional languages like Rust, Haskell, and OCaml: model errors as values. Instead of throwing, return a discriminated union that explicitly represents success or failure. The compiler then forces you to handle both cases.
In this chapter, you'll learn the Result<T, E> pattern, a type-safe alternative to exceptions. You'll build combinators that compose error-prone operations, and you'll see when to use Result vs. when to still throw.
The Problem with Exceptions
Consider a parser that might fail:
function parseJSON(input: string): any {
return JSON.parse(input); // Throws SyntaxError if invalid
}The signature says it returns any, but it doesn't mention that it can throw. Callers have no way to know:
- What exception types might be thrown?
- When should I catch?
- Did the maintainer forget to document this?
This leads to two failure modes:
- Over-catching: Wrap everything in
try/catch, slowing code and hiding real bugs. - Under-catching: Forget to catch, and the exception propagates to production.
Exceptions also break composition. You can't easily chain operations, collect multiple errors, or defer error handling. You must handle them right now, or let them bubble.
The Solution: Result<T, E>
A Result<T, E> is a discriminated union representing either success (with a value of type T) or failure (with an error of type E):
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };Functions return Result instead of throwing. Callers must check .ok to access the value or error, and TypeScript enforces this at compile time.
Now the signature documents that parsing might fail, and the compiler enforces that you handle both cases. No silent failures, no forgotten catches.
Building Helper Functions
Raw if (result.ok) checks get verbose. Let's build combinators to make Result ergonomic.
map<T, E, U>(result: Result<T, E>, fn: (value: T) => U): Result<U, E>
Transforms the success value if present, otherwise propagates the error:
map lets you transform success values without manually unpacking and repacking the Result.
andThen<T, E, U>(result: Result<T, E>, fn: (value: T) => Result<U, E>): Result<U, E>
Chains operations that also return Result:
andThen (also called flatMap or bind in other languages) flattens nested Results, letting you chain operations cleanly.
unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T
Extracts the value or returns a default:
Useful when you want a fallback without explicitly checking .ok.
Composing Operations with Result
Let's build a pipeline that parses input, validates it, and computes a result:
Notice:
- Each step is a separate function that returns
Result. andThenchains steps that might fail.maptransforms the final success value.- Errors propagate automatically — no manual
ifchecks at every step.
Multi-File Example: Parser with Result
Let's organize a parser into modules:
Here:
result.tsdefinesResultand its helpers.parser.tsusesResultto return success or error.main.tshandles the result explicitly.
This structure scales: any module can return Result, and consumers can compose with map/andThen.
Challenge: Implement Result.map and Use It
Complete the implementation of map in result.ts, then use it in main.ts to transform a parsed number by doubling it.
Hint: If result.ok, apply fn to result.value and return a new success Result. Otherwise, return the error unchanged.
When to Still Use Exceptions
Result isn't always the answer. Use exceptions for:
- Programmer errors: Bugs that should never happen in correct code (e.g.,
assert, null dereference). These should crash during development, not be handled gracefully. - Unrecoverable failures: Out-of-memory, stack overflow, or critical infrastructure failures that you can't reasonably handle.
- Third-party code: If a library throws, you might catch at the boundary and convert to
Result.
Use Result for:
- Expected failures: Validation errors, missing files, network timeouts, user input errors — anything that's part of normal operation.
- Composable operations: When you want to chain multiple steps and handle errors uniformly.
- Explicit contracts: When the caller must know a function can fail.
Comparison to Other Languages
This pattern isn't new:
- Rust:
Result<T, E>is built into the language, with?syntax for early returns. - Haskell:
EitherrepresentsLeft(error) orRight(success). - OCaml:
resultis a standard library type. - Go: Multiple return values
(value, error)force explicit checks (though not type-safe).
TypeScript doesn't have special syntax, but discriminated unions and type narrowing give you the same guarantees.
Multiple Choice: Result vs. Exceptions
What's the main advantage of Result<T, E> over exceptions?
Faster runtime performance
Errors are visible in the type signature and must be handled
Easier to write
Multiple Choice: Chaining with andThen
Given:
const result = parseNumber("4");
const sqrtResult = andThen(result, sqrt);
What happens if parseNumber returns an error?
sqrt is called with the error
sqrt is not called; the error propagates
The program throws an exception
Summary
You've learned how to:
- Model errors as values with
Result<T, E>. - Build combinators (
map,andThen,unwrapOr) for composable error handling. - Chain operations without manual error checks at every step.
- Organize
Result-based code across modules. - Decide when to use
Resultvs. exceptions.
Result shifts error handling from runtime guesswork to compile-time guarantees. Errors become part of your contract, visible in types and enforced by the compiler. This is a cornerstone of type-driven design: make illegal states unrepresentable.
Next: Scalable Architecture — structuring large TypeScript systems with layered architecture and dependency inversion.