TypeScripting Try-Catch

tl;dr:
notry-ts is a type safe error handling alternative to try-catch

Suppose you're asked to write a new function to determine whether a person can vote. It should be easy since there's already a getPerson function.

Notice that the function signature gives no hint that getPerson could throw. This is one problem we want to address.

Here's the new function.

And last but not least, the main progam.

Note here that the type of exceptionVar is unknown. If you make it something more specific you'll get a compiler error.

Catch clause variable type annotation must be 'any' or 'unknown' if specified.

This is another problem we'll try to tackle.

Test Cases

If you run the previous example, you should see three lines written to the console.

The first shows the output of a successful invocation of main.

Neil can't vote

The second invocation failed with what is probably a user error.

Error: Neal not found

The final result is another error but this time the meaning is less clear. It could be an environmental or maybe a programming error. In either case, it's not something we expect the user to be able to handle.

Error: Failed to get record

Unfortunately, this isn't terribly helpful. It means nothing to the user and doesn't give the developer much to debug with.

So how could we improve? A few ideas:

  1. Log the error stack trace: this would be helpful to the developer, but probably scare the user.
  2. Report the stack trace to the developer. On user errors, this would produce noise for the developer. On developer errors, the message would still be confusing to the user.
  3. Find a mechanism for differentiating error types.

Approaches

Let's explore idea #3 and compare error handling approaches using the three test cases.

try-catch

One simple solution is to throw subclassed Error objects.

That's better but the type checker doesn't help much. Without reading the getPerson source, we can't tell what (or even if) it could throw. exceptionVar still has type unknown. Narrowing it takes extra effort and if getPerson starts throwing something different, the compiler won't tell us.

Return Types

One way to lean on the type system for error handling is by using a specialized return type. We'll use a union of the successful return type and Errors to keep things simple.

This yields the same result as try-catch with the added benefit of type safety. The biggest downside is the extra check in canVote (more on that later).

Following this approach boils down to:

  1. Replacing throw with return in your code
  2. Updating the return types
  3. Propagating errors up the call stack
  4. Wrapping invocations of code you don't control in try-catch

There are some simple utilities you can write to make this easier. Still, one can't help but wonder - is there a better way?

notry-ts

What if we could get the convenience of try-catch and the type safety of return types? That's what I set out to answer when I created notry-ts. Let's see how our example looks with notry.

This produces the same output as the two previous solutions, so how does it work?

The first thing you'll notice is the new Why type.

Why represents the type returned when getPerson fails. It's used in the new first parameter of getPerson.

quit has the call signature (val: Why) => never. Calling it immediately terminates getPerson. So in this case, we could terminate getPerson by calling:

Rather than call quit directly, we use two convenience functions.

This quits with { type: "DevError" } if getRecord("person", name) throws, returning a Person otherwise.

This quits with { type: "UserError", message: "<name> not found" } if person is undefined.

canVote is straightforward. It takes a quit parameter of the same type as getPerson and forwards it.

At the top level, main invokes canVote with notry and assigns the result to did.

How it works

Here is the function signature of notry.

The generic types <Y, N, Args extends unknown[]> represent the successful return type, failed return type, and quitable arguments.

Any function that accepts Quit as it's first parameter is a Quitable.

So notry requires a Quitable function and arguments to pass it and returns a Did containing the result (either Y or N).

Did is a riff on the popular Either or Result types. It's a discriminated union that indicates whether quitable returned or quit. In this case, it'll be { y: { val: boolean } } or { n: { val: Why, exception: unknown } }.

The last piece of the puzzle is MaybeAsyncDid<Y, N>. It resolves to Did<Y, N> or Promise<Did<Y, N>> depending on whether quitable returns a promise.

Error Handling At Scale

In the preceding problem, the difference in error handling solutions might seem insignificant. Here we'll look at a fleshier example and try to understand how each approach scales.

The three external functions c, d, and e could throw so main catches and logs the exception. In the base case there is no way to differentiate the three possible failures.

try-catch

Let's introduce a utility safe to catch arbitrary exceptions and throw something specific.

The diff shows that it's an unobtrusive change.

However, no type safety.

Return types

This time we use a variant of safe that catch exceptions and returns something specific.

Unfortunately now three of the five lines in both a and b are error handling. It's easy to see how this can get unwieldy at scale.

But now we have type safety.

notry-ts

Finally, an implementation using notry.

Apart from the quit parameters, this looks more or less like the base case.

Admittedly, all those quit: Quit<Why> parameters are a bit cumbersome so, like anything, you should weigh the benefits against the costs.

Comparison

Let's wrap up by looking at what we get from each approach.

Type Safety

None with try-catch and roughly the same degree with return types and notry-ts including the ability to tell at a glance if and what errors could occur.

The biggest difference is that it's easier to ignore return types. If you call a function that normally returns void, you can easily forget to check whether an error occurred. This problem only affects notry-ts where notry is called.

Developer Experience

With try-catch, you could use a utility like safe to incrementally improve your error handling done with minimal effect on readability.

return types are intuitive since it's natural to associate a function's return value with its outcome. This solution requires the greatest amount of code and gains usefulness the more it's used.

notry has the least intuitive and most sophisticated typing. The function body's intent remains clear with error handling is mostly out of view. As with return types, its usefulness is dependent on how widely it's used.

Conclusion

If you care about type safety (why else would you be here), use return types or notry. Choose notry for added readability and safety at the cost of complexity.

Resources

package notry-ts
package neverthrow