An Idiosyncratic Blog

⏲ Async/Await and handling errors

Published on
·
7 minutes read

The Promise

async/await AKA how to write async code in 2021, was a result of callback hell and nested promises. What are those? Well take a look at the examples:

Promises, well...promised this:

doSomeFetch()
  .then((data) => doAnotherFetch(data))
  .then((result) => doAnoterFetch(result))
  .then((outcome) => probablyLastFetch(outcome))

Although, a real world use-case turned out like this:

doSomeFetch().then((data) => {
  doAnotherFetch(data).then((result) => {
    doAnoterFetch(data, result).then((outcome) => {
      probablyLastFetch(data, result, outcome).then((fin) => {
        // Ah Finally!
      })
    })
  })
})

And don't even get me started on error handling:

doSomeFetch().then((data) => {
  doAnotherFetch(data)
    .then((result) => {
      doAnoterFetch(data, result)
        .then((outcome) => {
          probablyLastFetch(data, result, outcome)
            .then((fin) => {
              // Ah Finally!
            })
            .catch((e) => {
              handleError(e)
            })
        })
        .catch((e) => {
          handleError(e)
        })
    })
    .catch((e) => {
      handleError(e)
    })
})

asnyc/await allows us to write code like this:

const data = await doSomeFetch()
const result = await doAnotherFetch(data)
const outcome = await doAnoterFetch(data, result)
const fin = await probablyLastFetch(data, result, outcome)

Errors..to catch or throw?

Looks cool right? Now how about we introduce some error handling:

let data, result, outcome, fin

try {
  data = await doSomeFetch()
} catch (e) {
  handleError(e)
}

try {
  result = await doAnotherFetch(data)
} catch (e) {
  handleError(e)
}

try {
  outcome = await doAnoterFetch(data, result)
} catch (e) {
  handleError(e)
}

try {
  fin = await probablyLastFetch(data, result, outcome)
} catch (e) {
  handleError(e)
}

An inquisitive mind like yours would ask, why not use a single try/catch and wrap all fetch call? That's a valid suggestion, however it depends on whether you need the catch to do something different depending on which function caused the error. A single try/catch would be something like this:

try {
  const data = await doSomeFetch()
  const result = await doAnotherFetch(data)
  const outcome = await doAnoterFetch(data, result)
  const fin = await probablyLastFetch(data, result, outcome)
} catch (e) {
  handleError(e)
}

If we want to be able to catch and identify which block threw an error and handle each case differently, we append a catch block to each async method, kind of similar to the promises way:

const data = await doSomeFetch().catch(handleError)
const result = await doAnotherFetch(data).catch(handleError)
const outcome = await doAnoterFetch(data, result).catch(handleError)
const fin = await probablyLastFetch(data, result, outcome).catch(handleError)

Nice right? But now each catch method has it's own context, and it's kinda difficult to use things decalared inside the catch method outside of it's context.

A Clean Approach

Building up on our previous examples, let's create a better error handler:

async function fancyFetch(fetcher, ...args) {
  try {
    const data = await fetcher(...args)
    return [data, null]
  } catch (error) {
    log(error)
    return [null, error]
  }
}

If we have data, we return an array with data as the first parameter and error as null, if we get and error, we return data as null and the error object.

Now to re-write up the async/await example using an array destructring syntax:

const [data, dataError] = await fancyFetch(doSomeFetch);
if(dataError) { // got the error context, do something! }

const [result, resultError] = await fancyFetch(doAnotherFetch, data);
if(resultError) { // got the error context, do something! }

const [outcome, outcomeError] = await fancyFetch(doAnotherFetch, data, result);
if(outcomeError) { // got the error context, do something! }

const [fin, finError] = await fancyFetch(probablyLastFetch, data, result, outcome);
if(finError) { // got the error context, do something! }

This feels much more cleaner in terms of readability. We even can add logging methods to the catch block which talks to some analytics service. As an added advantage, the codebase now has a consistent error handling mechanism. The fancyFetch method here is a very high level abstract. In the real world use-case, you'd want to add validations for the fetcher method and better logs in the catch block.

Takeaways

  • If you don't care about handling errors for individual async calls, go with a single try/catch.

  • If you care about handling errors for individual async calls, but do not care about the things inside the context of the catch block, use await doSomeFetch().catch(handleError);

  • If the answer for both of the above points were 'No', then the fancyFetch() approach should work for you.