Dataslope logoDataslope

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:

  1. Over-catching: Wrap everything in try/catch, slowing code and hiding real bugs.
  2. 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.

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

Notice:

  1. Each step is a separate function that returns Result.
  2. andThen chains steps that might fail.
  3. map transforms the final success value.
  4. Errors propagate automatically — no manual if checks at every step.

Multi-File Example: Parser with Result

Let's organize a parser into modules:

Code Block
TypeScript 5.7

Here:

  • result.ts defines Result and its helpers.
  • parser.ts uses Result to return success or error.
  • main.ts handles 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

Challenge
TypeScript 5.7
Result.map

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: Either represents Left (error) or Right (success).
  • OCaml: result is 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

QuestionSelect one

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

QuestionSelect one

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 Result vs. 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.

On this page