try / catch syntax is quite the norm for handling errors in most of C-style programming languages. However, it can become difficult to maintain readability in a codebase already filled with for, if, const, and function blocks. try / catch syntax also forces you to make an extra indentation. It can lead to a deeply nested code that is difficult to read and maintain.
So what?
Imagine you’re building an application, and has to use a library which provides a getTLD() function.
export function getTLD(urlStr: string): string {
const url = new URL(urlStr)
const tld = url.hostname.split(".").at(-1)
// Will throw error when URL is an IP address and doesn't contain real TLD
if (!tld || /^\d+$/.test(tld)) throw Error("Not a domain")
return tld
}Oh, sure, I could just wrap the callsite in a try / catch and do something when the URL is not a domain. But…
- It may seem like
getTLD()can only throw one type of error (Not a domain), but in reality, aTypeErrorcan also be thrown bynew URL()whenurlStris not a valid URL. So it’s not always clear what type of error might be thrown 🤷 - Even if we read the code carefully and then use a
try/catchwhen calling it, manually typing the error value is needed, because by default it’sanyorunknown(depends on yourtsconfig.json)
Another example: here we have three operations which we need to wrap with try / catch so we can produce different error message for each operations.
- Great. We can handle all the different types of error here. But at the cost of more nested sections and therefore losing a little bit of readability.
- Same as the 1st example, when
catch-ing something, the type ofeis also alwaysanyorunknown.
So there are three problems:
- Ambiguity in whether or not a function can throw an Error
- Ambiguity in what type of error a function can throw
- Slightly less readability
Copying the Gopher’s way…
We will borrow the Go’s style of error handling in TS. We begin with creating a Result type, which is a discriminated union of two tuples, one containing only the successful result, and one with only the error. And then we create a safe() function. The argument for the function needs to be a function, because else we can’t catch the error.
export type Result<T, E extends Error> =
| [T, null]
| [null, E];
export async function safe<T, E extends Error = Error>(
fn: (...args: any[]) => T | Promise<T>
): Promise<Result<T, E>> {
try {
const data = await fn();
return [data, null];
} catch (e) {
return [null, e as E];
}
}The function is pretty simple as it’s basically just a try / catch wrapper with extra steps. But we can simplify error handling, narrow down types effectively, and achieve cleaner code. We only need ifs. If needed, we can also infer the data and error result type, and/or cast them.
The doSomething() function can be updated as follows.
Looks a lot like Go.
Well, why bother?
- Discriminated union type
Resultmakes narrowing down the types easier and let us use Go’s style of error handling while still being typesafe. - Returning a tuple enables us to do declaration and assignment in the same line. It also enables us to destructure it and gives flexibility for naming.
const [name_it_whatever, you_like] = await safe(...)safeis an async function so that it can accept both synchronous and asynchronous function without trouble.- Now our code is much more readable than using a lot of
try/catchclauses. 🥳
But even then, please note that this is by no means a “best practice”. Even I would use the traditional try / catch when working with simpler stuff. This is just an attempt for exploring another style of error handling. It sure helps to make a complex logic to be more readable, especially when working with poorly documented third-party libraries.