0%

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.

Some of the default keyboard settings and shortcuts on macOS simply don’t work for me as a software developer. As soon as I get my hands on a new mac, I change some keyboard settings and shortcuts for general text editing, window management, iTerm2 and some other stuff. I’m going to go over everything I do step by step in this post. I’m sure you’ll find at least some of these tips & tricks pretty useful.

Read more »

Do you ever feel that the numerous productivity tools you use every day actually cost you more time than they are supposed to save? It sure doesn’t feel right when managing your tasks becomes a non-trivial task on its own. On the other hand, there’s no one-size-fits-all solution when it comes to task managers, so it makes sense to use the right tool for the job. To address this dilemma, I recently came up with a simple hack that lets me use all my favorite tools while interacting with just one.

Read more »

Grocery shopping has been one of my least favorite chores, even before the pandemic. Unlike many people I know, I always preferred online shopping over going to the supermarket. Now it’s not a matter of personal preference anymore; everybody stays at home and especially avoids crowded indoor places like supermarkets. It’s good to know that people are acting responsibly, but I never thought this could mean that I won’t be able to do online grocery shopping anymore!

Read more »

For the last few years, I’ve been spending a lot of my free time working on side-projects. What I came to realize is that it’s quite important that I plan ahead and work in an organized fashion except for very small projects. Trello is one of my favorite software tools ever, and I’m going to talk about how I manage my current side-project using its free version in this article.

Read more »

I’ve been learning about web development for a while pretty much from scratch. I’ve gone over numerous guides, tutorials and documentation from various resources, among which I took note of the important and beneficial ones that I believe a beginner will benefit from the most. In this article, I’m going to share them with you as a roadmap that you can follow if you want to become a full-stack web developer in a fun and efficient way.

Read more »

I’ve put together a YouTube Tutorial on building a serverless Firebase application that lets you insert transaction entries into your Google budget spreadsheet just by creating a Trello card from your mobile device. Throughout the the playlist, I demonstrate how to easily create a useful cloud application by wiring together Trello Webhooks, Google Sheets API, Firebase Cloud Firestore and Firebase Cloud Functions.

Read more »

If you use Google Spreadsheets for personal budget management and also like to get things done from the command line as much as possible, I have some good news for you. I’ve built a CLI app to insert transaction entries in monthly budget spreadsheets with simple commands from CLI. Today I’ll be walking you through the process of building this app.

Read more »

Today I’m going to walk you through the process of scraping search results from Reddit using Python. We’re going to write a simple program that performs a keyword search and extracts useful information from the search results. Then we’re going to improve our program’s performance by taking advantage of parallel processing.

Read more »