Giorgio Delgado

Type-Safe Error Handling In TypeScript

April 29, 2019

We've all been there before. We write a function that has to deal with some edge case, and we use the throw keyword in order to handle this situation:

type ResponseData = {
  statusCode: number
  responseBody?: ResponseBody
}

const makeHttpRequest = async (url: string): Promise<ResponseData> => {
  if (!isUrl(url)) {
    throw new Error(
      'Invalid string passed into `makeHttpRequest`. Expected a valid URL.'
    )
  }

  // ...
  // other business logic here
  // ...

  return { ... } // ResponseData
}

Now imagine a month later, you are working on your project when you or a colleague forget to wrap makeHttpRequest inside of a try / catch block.

Two things happen here:

So the question is:

How do we encode failability into the typesystem?

First, let's begin by acknowledging that throw is not typesafe. We must use a different approach in order to get the TypeScript compiler on our side.

What if we had a type or interface which represented the outcome of a computation that might fail?

Our type would represent two simple outcomes:

Let's call our type somethething intuitive like, Result. Let's call our Success variant Ok and our Failure variant Err.

Thus, if we were to formalize our type into code, it would look something like this:

type Result<T, E>
  = Ok<T, E> // contains a success value of type T
  | Err<T, E> // contains a failure value of type E

Going back to our makeHttpRequest function, we would want to encode the potential for failure into the typesystem.

Thus makeHttpRequest would have the following signature:

makeHttpRequest(url: string): Promise<Result<ResponseData, Error>>

And the function definition would look something like this:

// utility functions to build Ok and Err instances
const ok = <T, E>(value: T): Result<T, E> => new Ok(value)

const err = <T, E>(error: E): Result<T, E> => new Err(error)

const makeHttpRequest = async (url: string): Promise<Result<ResponseData, Error>> => {
  if (!isUrl(url)) {
    return err(new Error(
      'Invalid string passed into `makeHttpRequest`. Expected a valid URL.'
    ))
  }

  // ...
  // other business logic here
  // ...

  return ok({ ... }) // Ok(ResponseData)
}

Of course err(new Error('...')) seems a little tedious. But here are some things you should know:

... "But wait, how do I get the T value or E value that is wrapped inside of a Result<T, E>?"

Great question. Let me show you how. We're going to use a package I made [aptly] named neverthrow.

> npm install neverthrow

import { ok, err, Result } from 'neverthrow'

// we'll keep this simple
type ResponseBody = {}

interface ResponseData {
  statusCode: number
  responseBody?: ResponseBody
}

const makeHttpRequest = async (
  url: string
): Promise<Result<ResponseData, Error>> => {
  if (!isUrl(url)) {
    return err(new Error(
      'Invalid string passed into `makeHttpRequest`. Expected a valid URL.'
    ))
  }

  // ...
  // other business logic here
  // ...

  return ok({ ... }) // Ok(ResponseData)
}

So we're currently at the same place we were at with the last code snippet, except this time we're using the neverthrow package.

If you were to read through the neverthrow documentation you'd see that a Result has a .map method which takes the T value inside of a Result and converts it into anything you want.

Here's an example:

import { makeHttpRequest } from './http-api.ts'

const run = async () => {
  // unwrap the Promise
  // at this point
  // we have a Result<ResponseData, Error>
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result.map(responseData => {
    console.log(responseData)
  })
}

run()

But wait, what if the result variable contains an E value? in other words, it's an Err instead of an Ok.

Well, again, the docs for neverthrow show you how to handle this situation too ... Just use mapErr!

import { makeHttpRequest } from './http-api.ts'

const run = async () => {
  // unwrap the Promise
  // at this point
  // we have a Result<ResponseData, Error>
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result.mapErr(errorInstance => {
    console.log(errorInstance)
  })
}

run()

The most beautiful thing about Results is that they are chainable! Here's the above code in a more realistic example:

import { makeHttpRequest } from './http-api.ts'

const run = async () => {
  // unwrap the Promise
  // at this point
  // we have a Result<ResponseData, Error>
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result
    .map(responseData => {
      // do something with the success value
    })
    .mapErr(errorInstance => {
      // do something with the failure value
    })
}

run()

There is a lot more you can do with a Result type (check out the docs), but maping is the most important part of the API.

Making Your Types More Intuitive

If you start using Result a lot in your return types, you might notice two things:

To solve both issues, you could leverage type aliases!

Here's an example. Instead of having a function return a generic Result with a DbError as the E type, why not alias this type to something much more intuitive?

type DbResult<T> = Result<T, DbError>

Now your function can just return Promise<DbResult<T>>. It's a lot more succinct!

Further, your type now encodes meaning. The above type says that there's something async going on that could fail and I know that it's dealing with the database. Neat!

Here's a real-world example from one of my projects:

handler: (req: Request, res: SessionManager) => DecodeResult<Promise<RouteResult<T>>>

So handler is a function that does a few things:

I know exactly what's going to happen by only reading the types. And the beautiful thing is that I won't ever get runtime errors because none of my code throws (and all the 3rd party libs I depend on have been wrapped to return Results as well).

Summary

Liked this post? Consider buying me a coffee :)