Chapter 14

Type-Safe Error Handling: Result<T,E> Pattern

The Type System Blind Spot in try/catch

Before TypeScript 4.0, catch clause variables were implicitly any. 4.0 upgraded them to unknown — an improvement, but that just means you must narrow the type before using the error. The deeper problem remains: errors are invisible in function signatures.

// Signature tells you this returns User
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);  // invisible!
  const user = await res.json();
  if (!user)   throw new Error("User not found");      // invisible!
  return user;
}

// Caller: without reading the source, you don't know what can throw
try {
  const user = await getUser("123");
} catch (e) {
  // e is unknown — is it NetworkError? NotFoundError? ValidationError?
  if (e instanceof Error) {
    console.error(e.message); // works, but you've lost the error type
  }
}

The signature Promise<User> is silent about possible errors. This has two consequences: callers don't know what to handle; code reviewers can't judge reliability from the signature alone.


Four Error-Handling Approaches Compared

Approach Example Pros Cons
Throw exceptions JS tradition, Java Simple to write Errors invisible in signatures; callers can ignore them
Return null Common JS idiom Simple Error reason lost; null can be used without checking
Return [error, value] tuple Go style Errors visible Easy to forget to check first element; semantics unclear when both are null
Return Result<T, E> Rust, Haskell Full error type; compiler forces handling Requires helper functions; learning curve
// Approach 2: return null — reason lost
async function findUser(id: string): Promise<User | null> {
  // Network failure? Not found? Forbidden? All become null.
  return db.users.findById(id);
}

// Approach 3: Go-style tuple — easy to forget the check
async function findUser(id: string): Promise<[Error | null, User | null]> {
  try {
    const user = await db.users.findById(id);
    return [null, user];
  } catch (e) {
    return [e as Error, null];
  }
}

const [err, user] = await findUser("123");
user.name; // forgot to check err! runtime crash possible, no compiler warning

Implementing Result<T, E>

// Core type definitions
type Ok<T>          = { ok: true;  value: T };
type Err<E>         = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;

// Constructor helpers
function ok<T>(value: T): Ok<T> {
  return { ok: true, value };
}

function err<E>(error: E): Err<E> {
  return { ok: false, error };
}

// Type guards
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
  return result.ok === true;
}

function isErr<T, E>(result: Result<T, E>): result is Err<E> {
  return result.ok === false;
}

Chaining: map, flatMap, mapError

Checking result.ok on every step gets verbose. Combinators make Result composition fluent.

// map: transform the success value, pass through errors unchanged
function map<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => U
): Result<U, E> {
  if (result.ok) return ok(fn(result.value));
  return result;
}

// flatMap: chain an operation that can itself fail (monadic bind)
function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  if (result.ok) return fn(result.value);
  return result;
}

// mapError: transform the error type, pass through success unchanged
function mapError<T, E, F>(
  result: Result<T, E>,
  fn: (error: E) => F
): Result<T, F> {
  if (!result.ok) return err(fn(result.error));
  return result;
}

// Example: two operations with different error types
type ParseError   = { code: "PARSE_ERROR";   raw: string };
type NetworkError = { code: "NETWORK_ERROR"; status: number };

function parseId(raw: string): Result<number, ParseError> {
  const n = parseInt(raw, 10);
  if (isNaN(n)) return err({ code: "PARSE_ERROR", raw });
  return ok(n);
}

async function fetchUserById(id: number): Promise<Result<User, NetworkError>> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) return err({ code: "NETWORK_ERROR", status: res.status });
  return ok(await res.json());
}

// Chain: each error type is distinct and preserved
async function getUser(rawId: string): Promise<Result<User, ParseError | NetworkError>> {
  const idResult = parseId(rawId);
  if (!idResult.ok) return idResult;
  return fetchUserById(idResult.value);
}

Real Example: User Registration Flow

Registration has multiple independent failure points: email format, password strength, username availability, database write.

// Define all possible error types upfront
type RegistrationError =
  | { code: "INVALID_EMAIL";    email: string }
  | { code: "WEAK_PASSWORD";    reason: string }
  | { code: "USERNAME_TAKEN";   username: string }
  | { code: "DATABASE_ERROR";   message: string };

interface RegistrationInput {
  email: string;
  password: string;
  username: string;
}

interface User {
  id: string;
  email: string;
  username: string;
}

// Each validation step returns Result
function validateEmail(email: string): Result<string, RegistrationError> {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return err({ code: "INVALID_EMAIL", email });
  }
  return ok(email.toLowerCase());
}

function validatePassword(password: string): Result<string, RegistrationError> {
  if (password.length < 8)
    return err({ code: "WEAK_PASSWORD", reason: "Password must be at least 8 characters" });
  if (!/[A-Z]/.test(password))
    return err({ code: "WEAK_PASSWORD", reason: "Password must contain an uppercase letter" });
  if (!/[0-9]/.test(password))
    return err({ code: "WEAK_PASSWORD", reason: "Password must contain a digit" });
  return ok(password);
}

async function checkUsernameAvailable(
  username: string
): Promise<Result<string, RegistrationError>> {
  const existing = await db.users.findByUsername(username);
  if (existing) return err({ code: "USERNAME_TAKEN", username });
  return ok(username);
}

async function createUserRecord(
  email: string,
  password: string,
  username: string
): Promise<Result<User, RegistrationError>> {
  try {
    const user = await db.users.create({
      email,
      password: await hash(password),
      username,
    });
    return ok(user);
  } catch (e) {
    return err({ code: "DATABASE_ERROR", message: (e as Error).message });
  }
}

// Full registration flow — error types fully visible in the signature
async function registerUser(
  input: RegistrationInput
): Promise<Result<User, RegistrationError>> {
  const emailResult = validateEmail(input.email);
  if (!emailResult.ok) return emailResult;

  const passwordResult = validatePassword(input.password);
  if (!passwordResult.ok) return passwordResult;

  const usernameResult = await checkUsernameAvailable(input.username);
  if (!usernameResult.ok) return usernameResult;

  return createUserRecord(emailResult.value, input.password, usernameResult.value);
}

// Caller: every failure mode is known and handled precisely
async function handleRegistration(input: RegistrationInput) {
  const result = await registerUser(input);

  if (!result.ok) {
    switch (result.error.code) {
      case "INVALID_EMAIL":
        showFieldError("email", `Invalid email: ${result.error.email}`);
        break;
      case "WEAK_PASSWORD":
        showFieldError("password", result.error.reason);
        break;
      case "USERNAME_TAKEN":
        showFieldError("username", `"${result.error.username}" is already taken`);
        break;
      case "DATABASE_ERROR":
        showGlobalError("Registration failed. Please try again.");
        console.error(result.error.message);
        break;
    }
    return;
  }

  // result.value is User — fully type-safe
  redirectToDashboard(result.value.id);
}

Converting try/catch to Result

For APIs you don't control (JSON.parse, third-party libraries), wrap them once.

// Generic try/catch wrapper
function tryCatch<T>(fn: () => T): Result<T, Error> {
  try {
    return ok(fn());
  } catch (e) {
    return err(e instanceof Error ? e : new Error(String(e)));
  }
}

async function tryCatchAsync<T>(
  fn: () => Promise<T>
): Promise<Result<T, Error>> {
  try {
    return ok(await fn());
  } catch (e) {
    return err(e instanceof Error ? e : new Error(String(e)));
  }
}

// Usage
const parsed = tryCatch(() => JSON.parse(rawString));
if (!parsed.ok) {
  console.error("JSON parse failed:", parsed.error.message);
  return;
}
const data = parsed.value; // type-safe

// Wrap fetch
async function safeFetch(url: string): Promise<Result<Response, Error>> {
  return tryCatchAsync(() => fetch(url));
}

// Chain safeFetch with JSON parsing
async function fetchJson<T>(url: string): Promise<Result<T, Error>> {
  const fetchResult = await safeFetch(url);
  if (!fetchResult.ok) return fetchResult;

  return tryCatchAsync(() => fetchResult.value.json() as Promise<T>);
}

When to Still Use throw

Result is not universal. These situations warrant throwing:

// 1. Programming errors (invariant violations): states that should never occur
function getElement(index: number, arr: readonly unknown[]): unknown {
  if (index < 0 || index >= arr.length) {
    // This is a caller bug, not an expected runtime failure
    throw new RangeError(
      `Index ${index} out of bounds for array of length ${arr.length}`
    );
  }
  return arr[index];
}

// 2. Constructors: they cannot return Result
class Config {
  constructor(raw: unknown) {
    if (!isValidConfig(raw)) {
      throw new Error("Invalid config"); // no alternative
    }
  }
}

// 3. Truly unexpected errors (out of memory, process crash)
// These cannot be meaningfully handled in business logic — let them bubble up

// Rule of thumb:
// Result — expected failure paths in business logic
// throw  — programming errors, constructors, unrecoverable system errors

Antipattern: Result Inside Implementation Details

// Antipattern: private methods returning Result — unnecessarily verbose
class UserParser {
  private parseField(raw: unknown): Result<string, Error> {
    if (typeof raw !== "string") return err(new Error("not a string"));
    return ok(raw); // the only caller is this class — throw would be simpler
  }

  parse(data: unknown): Result<User, Error> {
    const nameResult = this.parseField((data as any).name);
    if (!nameResult.ok) return nameResult;
    // ...
  }
}
// Fix: throw internally, convert to Result at the public boundary
class UserParser {
  private parseField(raw: unknown): string {
    if (typeof raw !== "string") throw new Error("not a string");
    return raw; // clean and simple
  }

  parse(data: unknown): Result<User, Error> {
    return tryCatch(() => ({
      // parseField throws are caught by tryCatch and become Err
      name:  this.parseField((data as any).name),
      email: this.parseField((data as any).email),
    }));
  }
}

Summary

Approach Error Visibility Forced Handling Chainable Use When
throw Hidden in signature No No Programming errors, constructors
T | null Visible but reason lost No Poor Simple "not found"
[E, T] tuple Visible No Poor Not recommended
Result<T, E> Full typed error Yes Excellent Expected business failure paths

What's Next

Chapter 15 covers async/Promise types: how Promise.all infers tuple types, the Awaited<T> utility type, and the async error typing problem — combined with the Result<T, E> pattern from this chapter to build a complete async type-safety system.

Rate this chapter
4.6  / 5  (20 ratings)

💬 Comments