The standard try-catch-throw
approach to error handling in TypeScript is not type-safe, making it difficult to explicitly handle different kinds of errors in business logic. This might be okay in small projects where it’s affordable to treat all errors similarly, but it’s not ideal for larger and more complex projects where it’s oftentimes desirable to differentiate between recoverable and unrecoverable errors.
Limitations of standard error handling
Since the caught error is of type unknown
in the catch
block, we cannot rely on TypeScript in ensuring that all the possible error types are covered. Suppose that we have a function that can fail due to several reasons such as:
- Input decode (format) failure
- Input validation failure
- Unexpected database query result
- HTTP request timeout
- Other errors thrown by libraries
Some of these errors might be recoverable. Such errors should not bubble up to higher abstraction layers lest they disrupt the entire operation. Instead, they should be gracefully handled by the business logic.
For the sake of the argument, let’s assume that we want our system to recover from DecodeError
and ValidationError
in the example below:
1 | class DecodeError extends Error { |
If we wish to handle certain types of errors explicitly, we must make sure to cover all of them, and we get no help whatsoever from TypeScript. Moreover, if the above function evolves to produce additional types of (recoverable) errors, there’s a risk of forgetting to cover them in all the corresponding catch
blocks.
Before we start talking about a better approach, it’s important to note that
we cannot completely avoid dealing with thrown Error
s. This is because external (third-party and system) function calls will throw Error
s, even if our code doesn’t throw at all. Thus, we need to find a solution that can deal with unavoidable (foreign) Error
s too.
A simple type-safe alternative
Here’s all the boilerplate needed for this simple yet type-safe error-handling mechanism:
1 | export class Failure { |
After that, we only need to extend the Failure
class for specific types of errors we wish to handle explicitly:
1 | export class ValidationFailure<T = unknown> extends Failure { |
With this minimal toolkit, we can use importData
from the earlier example as follows:
1 | const importData = (rawData: unknown): ImportResult | DecodeFailure | ValidationFailure => { |
Note that all the possible failure types are visible in the function signature. TypeScript will alert us if we fail to cover all the possible failure cases at the call site; we won’t be allowed to pass a variable of type ImportResult | SomeFailure
to persistData
, which expects an ImportResult
.
Dealing with foreign errors
As mentioned earlier, we cannot prevent our dependencies from throwing errors. Therefore, we still need to rely on try / catch
blocks to handle those errors. Now comes the crucial principle:
Try, catch, but do not throw.
This means that in our own code we should never throw
an Error
of our own by extending the Error
class. Instead, we should extend Failure
and return it.
In try / catch
blocks at higher layers of abstraction, we may log errors, return HTTP responses, and so on. At lower layers (i.e. business logic), we may catch a foreign Error
and return it as a Failure
.
An Error
should be converted to a Failure
if and only if it needs to be explicitly handled it in the business logic. Assuming that most errors don’t require special treatment, Failure
s are supposed to be used for a very small subset of errors. In such cases, the conversion should be done at the earliest opportunity lest the Error
bubbles up to higher layers.
Here’s an example where a SyntaxError
thrown by JSON.parse
is converted to a ParseFailure
and returned:
1 | class ParseFailure extends Failure { |
Since we wish to explicitly handle SyntaxError
, we need to prevent it from bubbling up unchecked. By converting it to ParseFailure
, we force ourselves to explicitly handle it in our business logic.
Note that in the above example, the way we capture SyntaxError
is not type-safe. That’s because catching and matching it (via instanceof
) is the only option. On the bright side, anything that uses safeParseJson
has a way handle ParseFailure
in a type-safe manner.
Failure
not based on an Error
A Failure
doesn’t necessarily have to be created based on an Error
. We can create and return custom Failure
s in the business logic to represent an event/state/situation that isn’t a part of the happy path. For instance, if the business logic doesn’t allow a certain state, then we could return an IllegalStateFailure
rather than throw an IllegalStateError
and pray that it is handled properly (if at all) in all relevant catch
blocks.
Code style
On top of the lack of type safety, this standard error-handling approach makes the code messier too. To throw different kinds of errors from a function, we have to declare some variables via let
instead of const
(yikes!), and have a lot of try / catch
blocks that clutter the code.
To demonstrate this, compare the following two implementations of importData
from the earlier examples:
1 | // Standard error handling |
1 | // Type-safe error handling |
Other alternatives
There are some existing type-safe alternatives to the standard error-handling approach such as Either
(from fp-ts
) and neverthrow.
However, I prefer this solution because
- It’s much less intrusive than the aforementioned alternatives. For instance, using
Either
will likely expose you to more functional programming than you might be comfortable with. - You won’t have to unwrap the return value in the happy path. With
Either
andneverthrow
, the return value is wrapped withRight
andOk
, respectively. - Only 10 lines of boilerplate code, no external library dependencies.
Key takeaways
- TypeScript has a severe blind spot in type safety when it comes to standard error handling.
- Outside of
catch
blocks, create customFailure
s and return them instead of throwing customError
s. - Catch and convert an
Error
to aFailure
if it should be explicitly handled. If not (that is most of the time), let it bubble up. - By declaring all the possible
Failure
types in the signature, you can know much more about the behavior of a function at first glance.