Codementor Events

Type-Safe Error Handling In TypeScript

Published Oct 29, 2020
Type-Safe Error Handling In TypeScript

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:

  • The compiler is no longer able to tell you whether your code is safe from runtime errors. In other words, using throw is not typesafe. And is just as dangerous as any. They both dilute the benefits of using TypeScript in the first place.

  • Because neither the compiler nor the types tell you that makeHttpRequest can fail (read: throw), you will eventually get a runtime error. This is a waste of time, money and happiness for everyone. People begin to ask why they're using TypeScript if the compiler isn't helping them with something so basic as adding a try / catch block.

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:

  • Success case: Which would return / contain contain a "success value" (i.e. ResponseData in the case of makeHttpRequest)
  • Failure case: Which would return / contain helpful information about why the failure occurred

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:

  • The argument of the err function must be of type E, or you'll get a compile error (type mismatch) between the type inside of err and the return type of makeHttpRequest (where the E type is represented as an Error instance).

    • Relatedly, I just chose Error as the type for E for the sake of simplicity ... meaning E could be anything you want! More on this in a bit!
  • The user of makeHttpRequest can use this function without fear that it might randomly throw. No more runtime errors šŸš€

  • The author of the makeHttpRequest function also doesn't have to worry about writing and updating documentation every time a new edge case appears that would cause the function to throw an error. All of the behaviour of the function is encoded in the return type. Relatedly, the type serves as documentation now: "makeHttpRequest is an asynchronous function that can either succeed with ResponseData or fail with a Error."

... "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:

  • The meaning of the Results is not very clear

    • Example: The Result of a databse query might be something like Promise<Result<T, DbError>> while the Result of a network call might be something like Promise<Result<T, NetworkError>>.
  • The types are really long and verbose. For example, above we had a Promise<Result<ResponseData, Error>> ... this is a somewhat intimidating type!

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:

  • It does some decoding / deserializing of incoming data
  • It then does something async which generates a RouteResult with some data of type T

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

  • Avoid using throw if you can.

    • Users of your API are not required to catch (the compiler doesn't enforce it). This means you will eventually hit a runtime error ... it's just a matter of time
    • Using throw forces you to maintain documentation which will eventually become stale
    • If you do manage to maintain your documentation, a lot of people won't bother reading completely through the documentation, and won't realize that your functions throw in certain scenarios
  • Encode the potential for failure into your types using Results

    • The types are self documenting, and they cannot become "stale"
    • Users are given a friendly API that lets them deal with failable values in a safe way (using map and mapErr)
  • There's a fully-tested and type-checked npm package for this called neverthrow ... try it out!

Discover and read more posts from Giorgio Delgado
get started
post commentsBe the first to share your opinion
John Doe
3 years ago

Nice article. However, how can one force the consumer to use the Results in TS? If the consumer is only interested in performing some effect, there is a good chance that they wonā€™t be interested in Ok and therefore not use the API that would force them to handle Err, which is arguably worse than Exceptions since unused Result will silence the exception altogether. In languages like Rust, Haskell, Kotlin or Scala, Results are usually provided by the language or stdlib, and this problem is solved by checking the usage by compiler (usually implemented as a macro marker). There are no macros in TS, and I couldnā€™t find any linter annotation that would do this. I tried to work around this in TS, by creating a ā€œMustUse<T>ā€ wrapper, that wraps the value and immediately schedules a timer which will throw if .use(): T is not called immediately. This can be used in Result under the hood for example. It is still not ideal however, because it is checked at runtime, path must be executed, and it requires extra allocation and scheduling for every value that must be used, so it probably should be used only while developing. IMO, this is a big hole which makes usage of error handling ADTs in TypeScript almost worthless. What do you think?

Giorgio Delgado
3 years ago

Eslint would allow for creating a custom lint rule that would do exactly what Rustā€™s ā€˜must-useā€™ macro does at compile time.

See this issue: https://github.com/supermacro/neverthrow/issues/212


With that said, thereā€™s more to error handling ADTs than just the compile-time guarantee that an error is being handled. By using these sorts of types, you ultimately end up with a program that is more sound and is usually more logically structured than the alternative that uses exception throwing all over the place.

Show more replies