Effective Error Handling with Result Types in TypeScript

publish: April 7, 2024

modified: September 27, 2024

Elevate your TypeScript error handling with the powerful 'Result' type. Learn how to implement this functional programming concept for clearer, safer, and more composable code that explicitly manages success and failure states.

TypeScript, while powerful, lacks a built-in construct specifically designed for function-level error handling. The standard Error type, while useful, doesn’t quite fit the bill for representing potential failures in a type-safe manner. Let’s explore how we can fill this gap with a custom Result type.

The Result Type

The Result type encapsulates the outcome of a function that may succeed or fail. It adheres to two key principles:

  1. It can unwrap the underlying value.
  2. It allows building upon the underlying value.

Here’s a straightforward implementation:

enum ResultKind {
  OK = "Ok",
  ERR = "Err",
}

export type Result<T, E> = Ok<T> | Err<E>;

interface ResultBase<A, E> {
  kind: ResultKind;
  map<B>(fn: (_: A) => B): Result<B, E>;
  bind<B>(fn: (_: A) => Result<B, E>): Result<B, E>;
  match<B>(obj: { ok: (_: A) => B; err: (_: E) => B }): B;
}

export type Ok<A> = Readonly<ResultBase<A, never> & { kind: ResultKind.OK; value: A }>;

export function ok<A>(a: A): Ok<A> {
  return {
    kind: ResultKind.OK,
    value: a,
    map(fn) {
      return ok(fn(this.value));
    },
    bind(fn) {
      return fn(this.value);
    },
    match({ ok }) {
      return ok(this.value);
    },
  };
}

export type Err<E> = Readonly<ResultBase<never, E> & { kind: ResultKind.ERR; error: E }>;

export function err<E>(e: E): Err<E> {
  return {
    kind: ResultKind.ERR,
    error: e,
    map() {
      return this;
    },
    bind() {
      return this;
    },
    match({ err }) {
      return err(this.error);
    },
  };
}

Using the Result Type

Here’s how you might use the Result type in practice:

function divide(a: number, b: number): Result<number, string> {
  return b === 0 ? err("Division by zero") : ok(a / b);
}

// Example usage
const result1 = divide(10, 2);
const result2 = divide(8, 0);

// Pattern matching
result1.match({
  ok: (value) => console.log(`Result: ${value}`),
  err: (error) => console.error(`Error: ${error}`),
});

result2.match({
  ok: (value) => console.log(`Result: ${value}`),
  err: (error) => console.error(`Error: ${error}`),
});

Benefits of the Result Type

This implementation shares some properties with monads, though it’s a simplified version. The main advantages are:

  1. Improved function clarity: The return type explicitly indicates that an error might occur.
  2. Type safety: TypeScript ensures that both success and error cases are handled.
  3. Composability: Results can be chained together easily.

While it would be ideal to have such functionality at the language level, this pattern provides a workable solution for more robust error handling in TypeScript applications.

Conclusion

The Result type offers a pragmatic approach to error handling in TypeScript. By adopting this pattern, you can write more predictable and maintainable code. Consider incorporating it into your projects and see how it impacts your error management strategy.

While I encourage creating this pattern in your own projects, there are numerous projects that have amazing API’s like (ts-results)[https://github.com/vultix/ts-results] and