Simple and maintainable error-handling in TypeScript

elderfd

James Elderfield

Posted on May 31, 2021

Simple and maintainable error-handling in TypeScript

Sometimes things fail — that's a fact of life and programming. So as a programmer, you're going to have to write error-handling code. Thankfully TypeScript has some handy features which can help us to create simple and maintainable error-handling code.

At Supermetrics one error-handling approach we take is to encode error states into the TypeScript type system. What does this mean? Simply, I’m referring to code where the semantic property of "being an error" is indicated by a variable's type. For a simplified example:

// No information in the type that this is an error,
// you would have to inspect the value to check
let firstError: string = "Something terrible occurred";

interface TerribleError {
  code: "TERRIBLE_ERROR";
  message: string;
}

// It is clearly indicated in the type that this is an error,
// the exact value of the variable is less important
let secondError: TerribleError = {
  code: "TERRIBLE_ERROR",
  message: "Something terrible occurred",
};
Enter fullscreen mode Exit fullscreen mode

So, why is using the type system in this way so great?

1. Potential errors are indicated in function signatures

function doSomethingRisky(): TerribleError | number {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

As a consumer of this function, it’s clear that it may produce an error instead of the expected number. Some developers like to add documentation on potential errors to the function. While documentation is great, it isn’t tied closely to the code and it’s easy for docs and code to diverge over time - in this case either indicating errors that can never occur or missing new errors added later.

2. The compiler will not allow you to forget to check errors

Using the example function from point 1:

const riskyNumber = doSomethingRisky();

// Compiler error because you can't add a TerribleError and a number
const badComputedValue = riskyNumber + 2;

if (typeof riskyNumber === "number") {
  // This is ok as we've guarded against the error case
  const computedValue = riskyNumber + 2;
}
Enter fullscreen mode Exit fullscreen mode

This means you can't forget to check the errors, although it doesn't force you to handle them in any particular way. Simple static analysis like this is a great safety net for developers.

3. It can be used to standardize error handling

When you have a generic type like Error<E> where E is some wrapped data about the error, you now have a generic way of handling errors throughout your codebase. You may even want to go a step further and wrap the good path in some kind of Success type — we often use the pattern of a Result type that is defined as something like type Result<T, E> = Success<T> | Error<E>.

This is incredibly useful for writing generic code like this snippet which implements a function to call a potentially failing function with retries and could be used with any function returning your Result type:

function retry<T, E>(
  func: () => Result<T, E>,
  numberOfAttempts: number
): Result<T, E> {
  let value;

  for (const i = 0; i < numberOfAttempts; ++i) {
    value = func();

    // isError is a simple custom type guard implemented elsewhere
    // https://www.typescriptlang.org/docs/handbook/advanced-types.html#using-type-predicates
    if (!isError(value)) {
      return value;
    }
  }

  return value;
}
Enter fullscreen mode Exit fullscreen mode

Similar patterns can also be useful for many other cases like chaining operations that could fail, memoization of flaky functions, or handling errors from plugins or other 3rd party code.

4. Not all errors are the same

You’ll likely have operations that can fail in many exciting ways, which can also be encoded in these types. For example, by a discriminated union:

interface NetworkError {
  code: "NETWORK_ERROR";
  httpCode: number;
}

// Note that error types can have different properties to include only
// the necessary information
interface EndOfUniverseError {
  code: "END_OF_UNIVERSE";
}

function doVeryRiskyThing(): NetworkError | EndOfUniverseError | null {
  // ...
}

const maybeError = doVeryRiskyThing();

// These type guards cause the type of maybeError to be narrowed within
// the different scopes
if (maybeError?.code === "NETWORK_ERROR") {
  console.log(`Network request failed with code ${maybeError.httpCode}`);
} else if (maybeError?.code === "END_OF_UNIVERSE") {
  panic();
}
Enter fullscreen mode Exit fullscreen mode

5. Function polymorphism can be used to indicate when errors might occur

By having functions that are polymorphic in arguments and return types, you can write very general functions that provide rich information on when errors can occur. For a contrived example, let's say you have an in-memory cache as part of your application and a more-full-featured and longer-term cache as part of another service. You might use a simple flag on your cache function to indicate this like:

// Stores a value to local or remote cache with a given key
function cache<T>(key: string, value: T, useRemoteCache: boolean);
Enter fullscreen mode Exit fullscreen mode

Accessing the remote cache introduces many new failure modes, such as network errors. By writing polymorphic function definitions with your error types, you can indicate this:

// If using local cache then nothing interesting returned
function cache<T>(key: string, value: T, useRemoteCache: false): null;

// If using remote cache we may return a NetworkError
function cache<T>(
  key: string,
  value: T,
  useRemoteCache: true
): NetworkError | null;

// Implementation signature
function cache<T>(
  key: string,
  value: T,
  useRemoteCache: boolean
): NetworkError | null {
  // Implementation here
}
Enter fullscreen mode Exit fullscreen mode

Final words

The above patterns are by no means unique to TypeScript. For example, similar types are commonly used in functional-style programming in other languages, such as Result in Rust or Either in Haskell. You may also spot resemblance in some of these patterns to checked exceptions in Java or the mandatory error handling of error in Go.

It's very easy to build your own versions of the above error handling yourself, and in fact, I'd recommend it as a learning exercise if you want to become more familiar with TypeScript. But of course, there are many packages out there to help you. Some examples — in no particular order — include, purify-ts, fp-ts, and neverthrow. You’ll notice that a couple of those examples are functional programming libraries, this is because errors can be well-modelled with monads.


Learn more about Supermetrics as a workplace at supermetrics.com/careers/engineering.

💖 💪 🙅 🚩
elderfd
James Elderfield

Posted on May 31, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related