loading...

Type-Safe Error Handling In TypeScript

_gdelgado profile image Gio ・6 min read

Originally posted on my blog

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!

Posted on by:

_gdelgado profile

Gio

@_gdelgado

Freelance Software Engineer & Consultant GitHub: https://github.com/supermacro/

Discussion

pic
Editor guide
 

I think you have basically a good idea, but taking it to an extreme like this is not useful, IMO. I.e.:

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

True, but even with result ADTs, it's still just a matter of time, because runtime errors will always happen. You've changed your one "if not url, return the err result" line to not throw, but what about all of the code your function calls? I.e. you call randomNpmLibrary.whatever() or what not.

To literally never throw a runtime exception, you'd have to wrap your function implementation with a try catch:

try {
  ... regular code...
  if (boundaryCondition(...)) { return err(...);
  return result(...);
} catch (e) {
  return err(...); // something i called failed
}
Enter fullscreen mode Exit fullscreen mode

Anytime you call code (either yours or 3rd party) that a) hasn't moved over to the error ADT code style, or b) isn't 100% perfect in its adherence to the ADT code style and still accidentally NPEs/runtime exceptions when it isn't supposed to.

So, I think chasing "no runtime exceptions" is just a false/unattainable premise.

The other pitfall is that, if you want to represent "all failures return error instead of throw", eventually your ADT's error type expands and expands until they become super generic Result<Data | AnyError>. And you've lost any notion of the type-safety that you were after.

The reason is exactly what happened to checked exceptions; you start with Result<Data, DbError>. But now there is also an error if the user passes the wrong data, so you do Result<Data, DbError | ConfigError>. Now all your callers have to match on the new error (which we assert is good), or, they need to pass the new | ConfigError up to their callers, i.e. bubble up the error. So you're updating a lot of return values to do "oh right, add | ConfigError for my new error condition".

Which is exactly what happened with checked exceptions, having to add "oh right, also throws XyzException" all over your codebase whenever you discovered/added code for a new error condition.

So, to avoid updating all those places, maybe you make a more generic error type, like Result<Data, DataLayerError>, but now you need to convert all non-DataLayerErrors (like NoSuchMethod/etc.) into a DataLayerError, which is exactly like what ServiceLayerExceptions in checked exceptions did. :-)

Basically, at some point its actually a feature that "a random error can happen, it will blow up the stack, and you can catch it at some point on top".

Granted, for certain things, like network calls which are both extremely important and also almost certain to fail at some point, it makes a lot of sense to represent those as ADT return values, and force your caller to handle both success + networkerror. And so you're exactly right that it's a useful pattern to use there.

But I just wanted to point out that that I think taking it to the extreme of "my functions will never throw" and "I will use ADTs to represent any/all error conditions" is a slippery slope to a leaky abstraction, where Result<Data, Error> return values (i.e. its now very generic b/c it has to represent all failures, so how type-safe is it?) are doing essentially the same thing as catch but in a manner that is inconsistent and non-idiomatic with the runtime/rest of the ecosystem.

 

This is exactly what I was thinking reading this article. Is it really possible not to throw any errors? As long as you depend on any third-party libraries, there'll always be throw errors. Unless you completely control all of your codebase and they all follow your 'neverthrow' rules, you can't avoid some unexpected throw errors after all. So it's not an ideal method to handle typed errors.

 

This is not true. I'm using neverthrow in production right now at the company that I work at.

You have to make a distinction between code that you wrote that you know won't throw, and code that others have written that you are not sure will throw or not.

For example, at my company, we use knex for database access. Knex is a huge library and determining in which circumstances it will throw or not is not worth anyones time. Instead, database operations have try/catch arms to catch errors from knex.

By wrapping this kind of code inside of try/catch you guarantee a typesafe interface to the consumers of your functions. Here's an example:

import { ok, err } from 'neverthrow'

const getWidget = async (widgetId: number): Promise<Result<Widget, DatabaseError>> => {
  try {
    const widget = await db('widgets').select('*').where('id', widgetId);

    return ok(widget)
  } catch (e) {
    return err(getDbError(e))
  }
}
Enter fullscreen mode Exit fullscreen mode

Now knex is being used in a typesafe way.

Okay. I got your point. So when you need to use a third-party library that might be throwable you wrap it inside of a try/catch statement and turn it into a neverthrow style module.
But what if one of your colleagues added a new feature that uses a new third-party library and forgot to wrap it inside of a try/catch statement?

For example,

import { makeHttpRequest } from './http-api.ts'
import { throwableMethod } from 'third-party-lib'

const run = async () => {
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result
    .map(responseData => {
      // do something with the success value
      throwableMethod('this is a new feature')
    })
    .mapErr(errorInstance => {
      // do something with the failure value
    })
}

run()

Wouldn't it lead to a more serious problem because inside your core codebase(like in your business rules) you don't have any try/catch statements than when you have some try/catch statements somewhere so the thrown errors will be caught at some point and you can handle it before it causes any serious problems?

Since at the beginning of your article you pointed out that "what if you or a colleague forget to wrap makeHttpRequest inside of a try / catch block.", I think that the same thing could happen even if you stick to use neverthrow style because when you use something new that you are not sure if is throwable or not, you must wrap it inside of a try/catch statement.

If there's something I got wrong, please let me know what I misunderstood.
Actually I admire your idea and work. I'm just curious if it's really resolving the core issues.

But what if one of your colleagues added a new feature that uses a new third-party library and forgot to wrap it inside of a try/catch statement?

Yeah this definitely can occur. However, code review should prevent this sort of situation from happening too often. And it's ok. People make mistakes, no one's perfect. The team can retractively wrap the unsafe code in a try catch as part of their post-mortem.

This module isn't about creating the perfect codebase. Nor am I saying that you should never throw exceptions (how ironic). Strict adherence to dogma is never good!

What I am trying to say is this:

  • using throw as a control flow mechanism is suboptimal
  • modelling the various states of your functions (including whether your function could fail or not) within the type system leads to self-documenting code that is more obvious to others and easier to maintain

Wouldn't it lead to a more serious problem because inside your core codebase(like in your business rules) you don't have any ...

Hmm this depends at what depth the error is coming from and whether something higher up on the call stack does have a try / catch. The team needs to collectively agree to this sort of approach (hello coding guidelines!). This is more of a cultural / non-technical problem around enforcing a certain thing in your codebase.

Also, having a "catch all" try catch statement isn't really going to help you much. If you don't know what you're catching then you can't do much with it. So the counterargument in favor of having one catch statement at the top of your program is kind of irrelevant. All you can do is log the error? And maybe return a 500 to your users?

 

Thanks for your explanation, Stephen. Your words exactly describe my opinion.

 

It is good to see that type-safe development is gaining popularity.
Your Result type is actually an Either monad. Your implementation, however, lacks a few goodies — like Bifunctor instance (allows mapping on Ok and Err simultaneously with a bimap method). Take a look at fp-ts package — there's a lot of other stuff Either can do :)

 

Hey Yuriy,

Yup, it's the Either monad. But I intentionally omitted these terms (and others, such as "Algebraic Data Type") to show that functional programming can be practical and useful (not to say that it isn't).

The thing with Either is that it's really generic, and doesn't necessarily represent "the outcome of a computation that might fail". And although it's more convenient to others who are very familiar with functional programming, it adds to the slope of the learning curve (and makes functional programming seem again less practical).

I also chose to omit more useful APIs from the neverthrow package since simultaneous mapping can already be achieved through simpler means (using match), without having to teach people what a Bifunctor is. I took this approach because I am inspired by Elm's minimal design that tries to make functional programming as approachable and pragmatic as possible.

 

Beautiful!
As I read this I was thinking "hey Result is just an Either Monad"

 

The only thing I don’t like is that this brings us back to callbacks, which so elegantly got rid of with await.

 

Would be great if typescript allows pattern matching, then you’ll do

with(result){
   Err(e) => console.log(“error!”);
   Ok(value) => console.log(value);
}
 

Yes, that's nice. There are a couple of functional languages that support it. E.g. Erlang where I first saw this in action.

 

Maybe one day!

Actually you can do something similar:

blog.logrocket.com/pattern-matchin...

It’s not as pretty as real pattern matching but it fits to this use car.

 

One way I was able to deal with 'callback hell':

  • Add functional helpers like map, mapErr, biMap (match), flatMap (andThen) etc that can work with both Result... and Promise<Result...
  • Add .pipe function on Ok and Err, and Promise, pretty much as implemented in rxjs: accept to pass N amount of continuation functions with signature: (previous) => next

so now I can:

await getSomeRecordsAsync() // => Promise<Result<SomeRecords, SomeError[]>>
  .pipe(
    map(someRecords => someRecords.map(x => x.someField).join(", ")), // => Promise<Result<string, SomeError[]>>
    mapErr(err => err.map(x => x.message).join(", ")), // => Promise<Result<string, string>>
    flatMap(saveStringAsync), // => Promise<Result<void, someError>>
    // etc
  )

(with full Typescript typing support btw!)

Thinking about releasing it to GitHub and npm at some point :)

 
 

Hi! Thanks for explaining how throw is not type safe. That was eye-opening!

The makeHttpRequest function on the example is async. That returns a Promise.

Why not simply reject the promise instead?

 

Hey Julian,

Glad you enjoyed the article!

Well, using Promise.reject inside an async function is just an alternative to using throw.

Consider this example:

const doSomething = async () => Promise.reject(new Error('oh nooo'))

If you await on this function above, you will have to wrap it inside of a try / catch statement to prevent a runtime error. Further, there's no way you could encode the potential for failure into the above function. It's identical in behaviour to using throw ... So you lose all type safety.

 

Great article, and great lib. Thank you for spreading the concept of monadic error handling. I was also playing with it recently. I followed PromiseLike interface with my approach so, that it is actually possible to use async/await. I will be happy to hear your thoughts about it - npmjs.com/package/amonad

 

Hey Alexey,

Thanks for the kind words :)

Note that neverthrow implements a promisible interface as well.

See the ResultAsync docs here

 

Oh, it looks cool! What is an advantage of having ResultAsync as a separate abstraction?

You can think of Result as eagerly evaluated and ResultAsync as lazily evaluated. They're two entirely different mechanisms and thus can't be merged into a single abstraction.

 

Love this post. I have been writing a lot of Elm on side projects but use TypeScript at work. I recently encountered a big chunk of code that threw exceptions around. It was impossible to debug.

I found this post researching how I could refactor the code using Monads. Thanks.

 

This seems strongly inspired by Rust's type system. There, such a thing is built-in. It's Results all the way there…
And yeah, the syntax is very similar… (same names even)

 

Yes, i was thinking the same when i read it

 

i was have a bug happen only on production for a while and ignored, when you mentioned try and catch part at the beginning i found that exactly what causes the bug, thank you :D