Posted:

Safer Error Handling in TypeScript with the Result Type


Error handling in JavaScript and TypeScript has traditionally relied on exceptions using throw and try/catch. While this mechanism is simple, it comes with a hidden cost: you often don’t know what kind of errors a function can throw—especially when it calls other functions deep in a call chain.

As a result, many bugs arise not from code logic itself, but from missing or incorrect error handling. In large systems, this leads to fragile code and runtime crashes that are hard to trace.

This article explores a better alternative: the Result<T, E> pattern, a functional-style approach that makes all possible errors explicit, even across nested calls. This approach is inspired by languages like Rust.

Traditional Error Handling in JavaScript & TypeScript

Let’s look at a simple example of traditional error handling:

function riskyDivide(a: number, b: number): number {
  if (b === 0) throw new Error('Divide by zero');
  return a / b;
}

function computeResult(): number {
  return riskyDivide(10, 0); // Could throw!
}

Here’s the problem:

Why “just throwing” is brittle

AspectConsequence
ImplicitnessThe function’s type signature doesn’t reveal which errors might be thrown. Callers must consult documentation—or the source—to know what to catch.
Unchecked at compile-timeTypeScript’s compiler ignores throw paths. A forgotten try/catch silently becomes a potential runtime crash.
Nested surpriseEven if you don’t throw inside a function, a helper two levels down might. Troubleshooting becomes whack-a-mole.
Control-flow opacitythrow abruptly interrupts normal control flow, making code harder to reason about and test—especially in asynchronous chains.

Enter the Result<T, E> Pattern

The Result type is a discriminated union that returns either a successful result or a typed error:

type Result<T, E extends Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

Benefits:

Declaring Typed Errors

TypeScript uses structural typing, meaning two error classes with the same shape (e.g., just extending Error) are indistinguishable at compile time. Without adding a unique property like _tag, TypeScript treats different error types (e.g. DivideByZeroError vs. ThirdPartyError) as interchangeable.

By including a discriminant field like _tag, you enable precise type narrowing and exhaustive switch handling—just like discriminated unions elsewhere in TypeScript.

class DivideByZeroError extends Error {
  readonly _tag = 'DivideByZeroError';
  constructor() {
    super('Cannot divide by zero');
  }
}

class ThirdPartyError extends Error {
  readonly _tag = 'ThirdPartyError';
  constructor() {
    super('Third-party failure');
  }
}

Writing Functions with Result<T, E>

With the Result<T, E> pattern, functions no longer throw errors—they return them explicitly as part of the return type. This makes failure cases visible and forces the caller to handle them safely.

Here’s a simple example:

function safeDivide(a: number, b: number): Result<number, DivideByZeroError> {
  if (b === 0) return { ok: false, error: new DivideByZeroError() };
  return { ok: true, value: a / b };
}

This function guarantees that it will never throw. Instead, it either returns a successful result ({ ok: true, value }) or a known error (DivideByZeroError)—which is clearly indicated in the type signature.

Handling the Result

const result = safeDivide(10, 0);

if (result.ok) {
  console.log('Result:', result.value);
} else {
  switch (result.error._tag) {
    case DivideByZeroError._tag:
      console.error('Cannot divide by zero.');
      break;
  }
}

This approach helps eliminate unexpected exceptions and enables safe, predictable error handling across your codebase.

Nesting: Propagating Errors Up the Chain

Suppose we use a risky third-party call within our function:

function riskyThirdPartyCall(): Result<number, ThirdPartyError> {
  if (Math.random() < 0.5) {
    return { ok: false, error: new ThirdPartyError() };
  }
  return { ok: true, value: 42 };
}

We can now compose these:

function compute(): Result<number, DivideByZeroError | ThirdPartyError> {
  const division = safeDivide(100, 5);
  if (!division.ok) return division;

  const thirdParty = riskyThirdPartyCall();
  if (!thirdParty.ok) return thirdParty;

  return { ok: true, value: division.value + thirdParty.value };
}

You see all possible errors just by looking at the return type:

Result<number, DivideByZeroError | ThirdPartyError>

This is impossible to express with throw.

Pattern Matching and Exhaustive Error Handling

const outcome = compute();

if (outcome.ok) {
  console.log('Success:', outcome.value);
} else {
  switch (outcome.error._tag) {
    case 'DivideByZeroError':
    case 'ThirdPartyError':
      console.error(outcome.error.message);
      break;
    default:
      const _exhaustive: never = outcome.error;
      throw new Error(`Unhandled error: ${_exhaustive}`);
  }
}

The default case with never will trigger a compiler error if a new error type is added but not handled.

Comparison with throw

FeaturethrowResult<T, E>
Compile-time error awareness
Nested error propagation
Type-safe error handling
Requires explicit handling
Composability
Stack trace availability✅ (still uses Error)

Tradeoffs

But for critical systems—such as backend services, CLI tools, or libraries—the safety is worth it.

Conclusion

By replacing exceptions with explicit Result<T, E> types, TypeScript developers gain:

While it introduces a bit of verbosity, it pays off in correctness, testability, and maintainability—especially in larger codebases.

The next time you’re tempted to write throw new Error(…), consider returning a Result instead. Your future self (and your teammates) will thank you.

Full code example to play with

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

class DivideByZeroError extends Error {
  readonly _tag = 'DivideByZeroError';
  constructor() {
    super('Cannot divide by zero');
  }
}

class ThirdPartyError extends Error {
  readonly _tag = 'ThirdPartyError';
  constructor() {
    super('Third-party failure');
  }
}
function riskyThirdPartyCall(): Result<number, ThirdPartyError> {
  if (Math.random() < 0.5) {
    return { ok: false, error: new ThirdPartyError() };
  }
  return { ok: true, value: 42 };
}

function safeDivide(a: number, b: number): Result<number, DivideByZeroError> {
  if (b === 0) return { ok: false, error: new DivideByZeroError() };
  return { ok: true, value: a / b };
}

function compute(): Result<number, DivideByZeroError | ThirdPartyError> {
  const division = safeDivide(100, 5);
  if (!division.ok) return division;

  const thirdParty = riskyThirdPartyCall();
  if (!thirdParty.ok) return thirdParty;

  return { ok: true, value: division.value + thirdParty.value };
}

function main() {
  const outcome = compute();

  if (outcome.ok) {
    console.log('Success:', outcome.value);
  } else {
    switch (outcome.error._tag) {
      case 'DivideByZeroError':
      case 'ThirdPartyError':
        console.error(outcome.error.message);
        break;
      default:
        const _exhaustive: never = outcome.error;
        throw new Error(`Unhandled error: ${_exhaustive}`);
    }
  }
}

main()