Async/Await: a Game Changer for Haters of Promise Chaining

If you became a frontend engineer after 2015, chances are that you have used or at least heard about Promise. As I have covered in my previous blog, Promise can help us specify sequential relations between operations in asynchronous programming in a readable and maintainable manner. Promise object in JavaScript has methods, such as then and catch, that can help us organize the sequential execution of operations in a pattern called Promise Chaining. Clean and organized as Promise Chaining is, some people—especially those more accustomed to synchronous programming—find it unintuitive and confusing. Lucky for them, ECMAScript 2017 introduced a new feature that allows us to write asynchronous code as if it were synchronous. This new feature consists of two keywords: async and await. In this article, I will cover the fundamentals of async/await and how to transition from Promise Chaining to using async/await.

Await: a Wait for a Promise to Be Fulfilled

I will start with the keyword await. We use await when invoking a function that returns a Promise object. It blocks all subsequent operations until the completion (or failure) of this Promise object. It can also help assign the Promise object’s fulfillment value to a constant or a variable. The following is an example to illustrate its primary usage.

function getNumberOneInOneSecond() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(1)
        }, 1000)
    })
}

Let’s say we have a function called getNumberOneInOneSecond that returns a Promise object. When constructing this Promise object, we pass to the constructor an anonymous function whose argument is a resolve function. In the anonymous function, we use setTimeOut to invoke the resolve function with number one as the argument after one second.

If we initialize a constant with the returned value of getNumberOneInOneSecond, the constant will be an instance of Promise.

function getNumberOneInOneSecond() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(1)
        }, 1000)
    })
}

const numberOnePromise = getNumberOneInOneSecond()
console.log(numberOnePromise instanceof Promise)

However, intuitively, we expect the constant to be number one. This is where the await keyword comes in handy. We can use the await keyword to directly assign the fulfillment value of the Promise object to the constant numberOne. As mentioned above, await blocks all subsequent operations when the Promise object is still pending. Therefore, the console prints constant numberOne only after the Promise object is fulfilled.

function getNumberOneInOneSecond() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(1)
        }, 1000);
    })
}

async function main() {
    console.log('Getting number one...') // Executed immediately
    const numberOne = await getNumberOneInOneSecond()
    console.log(numberOne)
}
main()

Async: a Sanctuary for Await and a Factory Producing Promises

The await keyword helps us write synchronous-ish code in asynchronous programming, but we cannot use it wherever we want. You might be confused regarding why I decided to wrap await getNumberOneInOneSecond() in the main function and mark it with async in the code example above. Well, I certainly did not do it just for fun. I had to do it because we can only use the await keyword in functions marked as async.

In addition, async can transform a regular function into one that returns a Promise object. Let’s say we have a function called multiplyByTwo. It takes in one argument. First, it checks if the argument is a number. If the argument is a number, then multiplyByTwo multiplies it by two and returns the product. Otherwise, multiplyByTwo throws an error.

function multiplyByTwo(input) {
    if (typeof input === "number") {
        const output = input * 2
        return output
    } else {
        const error = new Error("Invalid Input")
        throw error
    }
}

If we add the async keyword ahead of the function, then it becomes a function that returns a Promise object. The returned Promise object is resolved with the return value of the original function and rejected with the error thrown by the original function.

async function multiplyByTwo(input) {
    if (typeof input === "number") {
        const output = input * 2
        return output
    } else {
        const error = new Error("Invalid Input")
        throw error
    }
}

It is equivalent to the following function, which I used as an example in the previous blog.

function multiplyByTwo(input) {
    return new Promise((resolve, reject) => {
        if (typeof input === "number") {
            const output = input * 2
            resolve(output)
        } else {
            const error = new Error("Invalid Input")
            reject(error)
        }
    })
}

As we can see, the async keyword allows us to write synchronous-looking functions in asynchronous programming. Not only can it liberate us from constructing Promise objects every time we want to return a Promise in a function, but it can also help us avoid including unfamiliar functions like resolve and reject in our codebase.

Try/Catch: You Can Even Handle Errors in a Synchronous Style

At this point, you might ask, “How can we handle errors without using the catch method?” The answer is surprisingly simple! We can use our good old friend—the try/catch construct.

async function multiplyByTwo(input) {
    if (typeof input === "number") {
        const output = input * 2
        return output
    } else {
        const error = new Error("Invalid Input")
        throw error
    }
}

async function main() {
    try {
        const output = await multiplyByTwo("2")
        console.log(output)
    } catch (e) {
        console.log(e)
    }
}
main()

To handle the error that multiplyByTwo might potentially throw, we wrap its invocation in a try block and define the error-handling operations in a catch block (in our case, we merely display the error).

Async/Await: Break Free from the Promise Chains

Next, I will demonstrate how we can use async/await to rewrite the following code example that utilizes Promise Chaining.

function addExclamationMark(input) {
    return new Promise((resolve, reject) => {
        if (typeof input === "string") {
            const output = input + "!"
            resolve(output)
        } else {
            const error = new Error("Invalid Input")
            reject(error)
        }
    })
}

addExclamationMark("Hello, world")
    .then(addExclamationMark) // the same as .then((input) => addExclamationMark(input))
    .then(addExclamationMark)
    .then(addExclamationMark)
    .then(addExclamationMark)
    .then((output) => console.log(output))
    .catch((error) => console.log(error))

We first add the async keyword in front of the definition of addExclamationMark. Then, we remove the construction of the new Promise object to unwrap the core operations. After that, we replace the invocations of resolve and reject with a return and a throw statement respectively.

async function addExclamationMark(input) {
    if (typeof input === "string") {
        const output = input + "!"
        return output
    } else {
        const error = new Error("Invalid Input")
        throw error
    } 
}

async function main() {
    try {
        const output1 = await addExclamationMark("Hello, world")
        const output2 = await addExclamationMark(output1)
        const output3 = await addExclamationMark(output2)
        const output4 = await addExclamationMark(output3)
        const output5 = await addExclamationMark(output4)
        console.log(output5)
    } catch (error) {
        console.log(error)
    }
}
main()

To replace the chain of promises, we separate the five invocations of addExclamationMark completely. We place await ahead of each invocation of addExclamationMark to assign the output to a new constant. Also, we pass the output of the previous invocation of addExclamationMark as an argument to the next one. Last, we display the final output by calling console.log(output5).

As for the error handling operations previously wrapped in the catch method, we now place them in a catch block. Proceeding the catch block is a try block containing all the operations we want to perform if nothing goes wrong, including the five invocations of addExclamationMark.

You Can Have the Best of Both Worlds

For those who do not want to fully commit to using async/await, here is some good news: you CAN have the best of both worlds! Let’s say you are sold on the idea that using async is a better way to define asynchronous functions (functions that return a Promise object). However, you are unwilling to forfeit your freedom to use Promise Chaining. Instead of reluctantly inundating your codebase with the await keyword, you can use async with Promise Chaining. You can even mix await with promise chains if it is your desired style. The world is your oyster!

In this article, I presented the fundamentals of async/await—a powerful alternative to the usual way of organizing sequential execution of operations (Promise Chaining) in asynchonous programming. First, I explained how to use async/await and the conditions for using them. Then, I demonstrated how to rewrite asynchronous code with async/await. Last, I mentioned that it is totally acceptable to use async and await separately and selectively without absolute commitment. I hope this article has inspired you to start using, or at least consider using, async and await.