0%

Try, catch, but don't throw

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class DecodeError extends Error {
// …
}
class ValidationError extends Error {
// …
}

// may throw DecodeError or ValidationError
// (but that's not visible in the signature)
const importData = (rawData: unknown): ImportResult => {
// …
}

// call site
try {
importData(rawData)
} catch (err) { // err is of type `unknown`
if (err instanceof DecodeError) {
console.warn('Decode error:', err)

// gracefully handle decode error

return
}

// oops, forgot to handle ValidationError

throw err
}

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 Errors. This is because external (third-party and system) function calls will throw Errors, even if our code doesn’t throw at all. Thus, we need to find a solution that can deal with unavoidable (foreign) Errors too.

A simple type-safe alternative

Here’s all the boilerplate needed for this simple yet type-safe error-handling mechanism:

1
2
3
4
5
6
7
8
9
10
export class Failure {
public get name(): string {
return this.constructor.name
}
}

export const isFailure =
<F extends Failure, T>(f: new (...args: Array<any>) => F) =>
(m: T | F): m is F =>
m instanceof f

After that, we only need to extend the Failure class for specific types of errors we wish to handle explicitly:

1
2
3
4
5
6
7
8
9
10
11
12
export class ValidationFailure<T = unknown> extends Failure {
rawObject: T

constructor(rawObject: T) {
super()
this.rawObject = rawObject
}
}

export class DecodeFailure extends Failure {
// …
}

With this minimal toolkit, we can use importData from the earlier example as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const importData = (rawData: unknown): ImportResult | DecodeFailure | ValidationFailure => {
// …
}

const persistData = (importRes: ImportResult) => {
// …
}

// call site
const res = importData(rawData)
if (isFailure(DecodeFailure)(res)) {
// handle decode failure
return
}
if (isFailure(ValidationFailure)(res)) {
// handle validation failure
return
}

persistData(res) // res must be of type `ImportResult`

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, Failures 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ParseFailure extends Failure {
// …
}

const safeParseJson = (serializedData: string): object | ParseFailure => {
try {
return JSON.parse(serializedData)
} catch (err) {
if (err instanceof SyntaxError) {
return new ParseFailure(err)
}

throw err // let all other kinds of errors bubble up
}
}

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 Failures 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Standard error handling
const importData = (rawData: unknown): ImportResult => {
let decodedData
try {
decodedData = decode(rawData)
} catch (err) {
throw new DecodeError()
}

let validatedData
try {
validatedData = validate(decodedData)
} catch (err) {
throw new ValidationError()
}

// …
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Type-safe error handling
const importData = (rawData: unknown): ImportResult | DecodeFailure | ValidationFailure => {
const decodedData = decode(rawData)
if (isFailure(DecodeFailure)(decodedData)) {
return decodedData // return the failure (DecodeFailure) as-is
}

const validatedData = validate(decodedData)
if (isFailure(ValidationFailure)(validatedData)) {
return new OtherFailure(validatedData) // return a new failure
}

// …
}

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 and neverthrow, the return value is wrapped with Right and Ok, 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 custom Failures and return them instead of throwing custom Errors.
  • Catch and convert an Error to a Failure 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.