Handling Errors With Async Await
We can do better than wrapping everything in a try/catch
If you've been working with JavaScript for a while, you are probably familiar with the term "callback hell". If you aren't, it refers to the way we used to handle async code in our applications by passing a callback to a function and then waiting for the callback to be fired.
Callbacks
const waitASecond = (callback) => {
setTimeout(() => {
callback("done");
}, 1000);
};
const handler = () => {
waitASecond((result) => {
console.log(result);
});
};
The reason this pattern led to what was affectionately known as "callback hell" is because often these sorts of functions would be deeply nested. This meant that a function several levels up would have to wait until a function several levels deep would trigger the callback. The problem became even worse because the callback could be called from anywhere in the nested functions.
The answer to this problem came in the form of promises. For example:
Promises
const waitASecond = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve("done");
}, 1000);
});
const handler = () => {
waitASecond().then((result) => {
console.log(result);
});
};
However, many developers didn't like the nested structure of the promise pattern, so another solution was built on top of promises. This was called "async/await".
Async/Await
const handler = async () => {
const result = await waitASecond();
console.log(result);
};
The async/await pattern definitely feels more natural and readable than either of the other two options, but this is completely ignoring error handling. Unless a function is completely deterministic, there is always a possibility that it could throw an error. With async/await, we have two tools in our belt to handle this:
.catch()
const handler = async () => {
const result = await waitASecond().catch((error) => {
console.log(error);
});
console.log(result);
};
The problem with using a traditional promise .catch() method is that it doesn't branch the code if an error occurs. The result variable will be undefined, which means we would have to do additional falsy checks to make sure we didn't get an error.
Another option is to wrap the entire request in a try/catch block.
try/catch
const handler = async () => {
try {
const result = await waitASecond();
console.log(result);
} catch (error) {
console.log(error);
}
};
While this does branch the code if an error occurs, if we have multiple awaits, our code will quickly start looking like a plate of spaghetti.
So here's another option. What if we wrote an async function that returns an array of two values? The first value will be the error if one occurs, and the second value will be the result.
Wrapped in a handle function
const handler = async () => {
const [error, result] = await handle(waitASecond());
console.log(result);
console.log(error);
};
This definitely looks better than the previous options. We can even chose to just use the error or just use the result if needed by destructuring the array. We can branch the code based on the error or result, but we have the flexibility to do the branching wherever we want on our own terms.
So what does this handle function look like? We basically need to attempt to resolve the promise, and if an error occurs, we need to return an array with the error and null as the result. Otherwise, we return an array with null and the result.
The handle function
const handle = (promise) =>
promise
.then((data) => [undefined, data])
.catch((error) => Promise.resolve([error, undefined]));
I've gone through a handful of implementations of this handle function across the companies I've worked for over the years and I finally ended up putting it inside an functional async library called CartesianJS. There are several other really convenient functions in that library as well, but if you want a quick off the shelf handle function, this has worked really well for me.
How do you handle errors in your async code? How do you balance concise code with defensive code? Let me know in the comments below.



