Making exceptions type safe in Typescript
EDIT: The ideas presented in this post have been released as a library on npm (https://github.com/dhruvrajvanshi/ts-failable) so check that out.
Many times I’ve wished that I could reason about exceptions in a type safe manner. Like getting a specific field from the exception to handle the error. But in Typescript, exceptions have any
type by default. You can throw whatever but when you catch it, it will have any
type.
EDIT: Some people have pointed out that this just the Either type. Its true that its equivalent to Either type but its more than that. It gives a way to flatten out nested flatMaps so if you have multiple computations that can fail and each depends on the result of the previous, you’ll have as many levels of nesting as there are failable calls depending on the last one. BTW, here’s another article I wrote that solves the nesting problem for Monads in Typescript by simulating Haskell’s do notation. You can use that too for the same effect but I think the syntax for this method is closer to exceptions and less alien.
The alternative to using exceptions is use discriminated unions to indicate the possibility of an error. Something like this.
// So you like boilerplate like if (result.isError) {...}
// Just use Go :/type MyResult<T> = {
isError: true,
error: ErrorType} | {
isError: false,
value: T}
This will force the user of a MyResult<T>
value to check the isError
field on the result to access the value
field. There’s no way around it which is good. However, this approach has a small problem which is not present when using exceptions. Error propagation.
Consider this. If you have two functions that return MyResult
values and you want to call them after each other, with the return value of the first call being used as an argument to the second call, you’ll have to check each of them for errors seperately. This can become tedious in case of multiple failable steps in your computation.
If you had used exceptions, you could have called them like normal functions and the errors thrown by each inner function would have propagated up, essentially “short circuiting” the evaluation if any step failed.
What if I told you that you could have type safety as well as error propagation at the same time?
Checked Exceptions (ignore your gag reflex for now)
Java has this feature that lets you annotate a method’s return type with the types of exceptions it can throw. The caller can either handle the error (using try/catch), or add a throws
clause to the return type to indicate that this method too can throw the exception.
What? You ask. After what we’ve been through in Java? Really? To which I say, Typescript is not Java. The only reasonable arguments against Java’s checked exceptions are:
- Adding a new exception type to a function that’s being called deep in the stack requires changing the signature of all the functions that call it.
throws
clauses aren’t first class types.
The solution to the first problem is there in Typescript. You can alias a function’s errors by doing
type MyFuncErrors = Error1 | Error2 | Error3
If your function needs to throw a new type of error, you can simply do add it to MyFuncErrors
and every function that propagates the function will remain unchanged provided the function’s type contains MyFuncErrors
and not its cases (which is agains DRY principle anyway).
The functions that catch MyFuncErrors
will have to change and the compiler will tell you that. Adding a new case shoud force you to do that.
The idea
Here’s how it will work.
We make a new type called Failable
that is generic over the type of the result it can contain and type type of errors it can throw. Failable<T, E>
is returned by a function that can return a value of type T
on success, or an error of type E
in case of failure.
The type is straightforward enough. If there’s an error, isError
is true
and there will be an error
field, containing the error. As per Typescript rules, you’ll have to check isError
field to read the value
or error
.
Next, we need a way to create these Failable
values. Writing out object literals is verbose and doesn’t help with error propagation. Take a look at this signature as this is the most important thing.
This might be a bit hard to read but it captures the entire idea.
So, you create a value of Failable<T, E>
by calling the failable
function.
To it you pass in a lambda function that does whatever computation it needs and returns an error or value. This lambda function will be passed some arguments by failable
that would help in indicating success or failure. For now, ignore the run
argument.
Here’s an example that simulates a database call. DB calls can fail so that is encoded in its signature. It either returns a Row
or “throws” a DB_ERROR
or a USER_DOESNT_EXIST_ERROR
.
The signature of getUserRow
tells you that it takes a string
(query), and can either successfully return a Row
or undefined
, or, in case of failure, it returns a DB_ERROR
object. For creating a Failable
, it calls failable
with the computation. It gets success
and failure
functions as arguments. Note the destructuring syntax here. Its not strictly required but makes the API a little nicer.
The good thing here is that the types of success
and failure
are such that returning something other than the given exception type to failure
, or something other than the success type to success
is not possible.
You can think of success
as return
and failure
as throw
. Calling failure
actually throws an exceptions under the wraps to do error propagation. success
simply wraps its argument into a Failable
object.
To explain sequencing, I made another function for converting a Row
object to a User
object. This can also fail and give a PARSE_ERROR
.
Now comes the fun part. How to we call both of these in sequence without explicit error handling, while making sure that an error on any step will be propagated up the call stack?
Here’s a simple function that uses these two functions to get a user from the database.
As you can see, ther’s no explicit error handling done on each step. If getUserRow
fails, the the computation stops at the call site and the error is propagated. run
function makes sure that if its argument function returns a Failable
value having an error, it is thrown. The failable
function makes sure that the exceptions thrown by run
are converted into Failable
values.
Also, run
can only be called with a function returning a Failable
whose error type is a subtype of the error type of itself. You can test this by removing | DB_ERROR
from the type signature. This will result in a type error on the call to getUserRow
. We need to either change the type of getUser
to include the thrown DB_ERROR
, or we need to “catch” and handle it.
The way we can catch a failable computation is by calling it directly instead of passing it to run
. If we call it directly, we get a simple object of Failable
type that we can check for error using simple if statements. Again, if isError
is true, an error will be present and if its false, a value
is returned.
The implementation
If you understand the type and semantics of failable
function, its straightforward to implement. I won’t bother explaining much and will just give it to you in code. The most interesting thing about it is the fact that failable
needs to differentiate between exceptions thrown by run
blocks and other exceptions. It should catch the ones thrown by run
and convert them to Failable
objects with isError: true
and throw the other exceptions as it is. This is done by creating a helper class Failure
. run
wraps the error value of its computation into this type and throws it. Now, failable
can simply check if the error was thrown by run
using an instanceof Failure
check. AFAIK, making a new class/prototype is the only foolproof way to check if an object was created by a particular function. You obviously won’t expose this Failure
class to the outside world so that its instances can only be created by run
.
Here’s the implementation.
You can see that success
and failure
are not strictly required. They just wrap up the result/error into a Failable
value. Also, I saw that always creating Failables using these functions localized the type errors better.
Better API
Edit: Someone pointed out on Reddit that you don’t need `run` to take a function that returns a `Failable`. Simply passing the `Failable` will do and would clean up the calls.
One obvious way the API of Failable
can be made better is by adding a .map
method (and maybe even a .flatMap
) on it. So, if you have a value of type Failable<number, SomeError>
, you could call .map(x => x.toString())
to get a Failable<string, SomeError>
. flatMap
isn’t that important because run
is kind of does that already. So, yes Failable
is a monad…and monads are burritos.
Also, it would also make it way nicer if you implement .mapError
method on it so that if you’re calling a function that returns a Failable
with a different error type than your context, you can convert it to an error of your given type inside run
so you can call it normally.
Final thoughts
I think this is a decent ways to have type safe exception handling in Typescript. Unlike Java, where adding a throws clause makes methods tedious to use, in this way, the throws clause is simply a generic argument to the return type. It is a simple type and it can be aliasesd to avoid cascading of type signature changes when adding a new error. Other than that, checked exceptions weren’t that bad anyway. Java’s checked exceptions occasionally made it tedious to do the right thing (adding throws clause along long chains of calls) which made them such a pain. Otherwise, it was a good idea. Heck no I think of it, Java’s checked exception problem can be easily solved by type aliases and union types.
This way is also similar to Rust’s Result
type and ?
operator, with worse type errors. This is expected because Rust’s handles ?
operator in the compiler. Also, Rust can semi automatically converts error types if there’s an implementation of a Convert
trait between those types. Rust is one of the languages that has really good error handling thanks to the ?
operator and I hope other languages steal this idea.
I might package this into a library if I get the time. You have the implementation here anyway if you need it.
Standard Disclaimer: Don’t pay too much attention to the actual implementation just understand the type signatures and the meaning of the functions. The implementation might have bugs.