TypeScripting 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:
- Log the error stack trace: this would be helpful to the developer, but probably scare the user.
- 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.
- 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
Error
s 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:
- Replacing
throw
withreturn
in your code - Updating the return types
- Propagating errors up the call stack
- 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.