DEV Community

Cover image for Write Declarative, Immutable and Flattened Code by Combining Promise and Async Await. The Great Escape From The 4 Hells
Acid Coder
Acid Coder

Posted on • Edited on

Write Declarative, Immutable and Flattened Code by Combining Promise and Async Await. The Great Escape From The 4 Hells

(Note: Promise here refer to Promise Chain)

Try to google "mix promise and await" , the first two results tell you not to do so

Image description

The first result is basically the author forgot to await his promise.

The same error can happen if he go pure Async Await (without using any .then and .catch) and forget to await his Promise.

It doesn't matter whether he is mixing Promise and Async Await or not. If he does not await his Promise AGAIN, he is f*ked.

The 2nd result was because OP forgot to return his promise.

It doesn't matter whether he is mixing Promise and Async Await or not. If he does not return his Promise AGAIN, he is f*ked.

People quickly jump into conclusion like "it is because you mixed promise or async await" but ignoring the fact that they will repeat the same mistake with pure Promise or pure Async Await style.

Code Inconsistency...?

I get it, people like to store blue ball in a blue box. I can feel my blood boiling when I see someone threw food waste into recycle bin that was meant for plastic.

But can we stop and think, what actually make a code inconsistent?
can you write a consistent Promise code?
can you write a consistent Async Await code?
can you write a consistent Promise + Async Await code?

The answer is a 100% yes

can you write a inconsistent Promise code?
can you write a inconsistent Async Await code?
can you write a inconsistent Promise + Async Await code?

The answer is also a 100% yes

The key to write a consistent code is not only about what language APIs to use, but it is also about how we use language APIs.

We are oversimplifying code inconsistency if we think our code is inconsistent just because we are using 2 language APIs that are doing the same thing.

In this article, I am going to show you not only writing consistent Promise + Async Await code is easier than writing consistent Promise or consistent Async Await code, I will also show you why Promise + Async Await code are easier to reason with and can lead to a better code consistency.

Hells

To write a good Promise + Async Await code, we need to look at how styles of both language APIs affect our code structure and understanding each approaches weaknesses and strengths

Functionality wise, Promise and Async Await do exactly the same thing. They have similar complexity, similar readability and suffering from conceptually similar hells.

I describe Promise as horizontal, because Promise has horizontal readability and suffering from a horizontal hell

I describe Async Await as vertical, because Async Await has vertical readability and suffering from a vertical hell

Both Promise and Async Await suffer from one common hell: nesting hell

Here we are going to demonstrates all hells by using a common example, the conditions are:

  1. We need to make 3 out bound API calls, the latter calls require the data from the previous call.
  2. We need to handle each API call error.

Promise's Hells

Let us solve the problem with Promise and go through options that we have.

Then we analyze what make them frustrating to work with.

Callback Hell

fetchOne()
    .then(data => {
        return fetchTwo(data)
            .then(data => {
                return fetchThree(data)
                    .then(data => {
                        // handle data
                    })
                    .catch(err => {
                        // handle error
                    })
            })
            .catch(err => {
                // handle error
            })
    })
    .catch(err => {
        // handle error
    })
Enter fullscreen mode Exit fullscreen mode

This is Promise's nested hell, also known as Callback Hell

You don't like it? Fine, try Bridge Hell

Bridge Hell

fetchOne()
    .catch(err => {
        // handle error
    })
    .then(data => {
        return fetchTwo(data).catch(err => {
            // handle error
        })
    })
    .then(data => {
        if (data) {
            return fetchThree(data).catch(err => {
                // handle error
            })
        }
    })
    .then(data => {
        if (data) {
            // handle data
        }
    })

Enter fullscreen mode Exit fullscreen mode

Introducing Promise horizontal hell, I like to call it Bridge Hell.

We can read everything from left to right.

Other than slightly confusing and not how the usual way we read code(from top to bottom), it has no serious flaw

But some programmers may find this difficult to reason with.

Async Await's Hells

Many people believe Async Await is solution to Promise's Hell, this is due to most programmer start their journey with procedural programming, it is our habit and we love our habits dearly.

But if we look closely, Async Await does not solve anything, it has the same problem with Promise, but flipped 180 degree.

We simply jump from "I don't like this" hells to "I like this" hells.

Scope Hell

const abc = async () => {
    try {
        const data = await fetchOne()
        try {
            const data2 = await fetchTwo(data)
            try {
                const data3 = await fetchThree(data2)
                // handle data3
            } catch (e) {
                // handle error
            }
        } catch (e) {
            // handle error
        }
    } catch (e) {
        // handle error
    }
}
Enter fullscreen mode Exit fullscreen mode

This is Async Await's nesting hell, I like to call it Scope Hell

And of course, if Promise has horizontal hell(Bridge Hell), then Async Await also has vertical hell, introducing Tower Hell:

Tower Hell

const abc = async () => {
    let data = null

    try {
        data = await fetchOne()
    } catch (e) {
        // handle error
        return
    }

    let data2 = null

    try {
        data2 = await fetchTwo(data)
    } catch (e) {
        // handle error
        return
    }

    try {
        const data3 = await fetchThree(data2)
        // handle data3
    } catch (e) {
        // handle error
    }
}
Enter fullscreen mode Exit fullscreen mode

This is Async Await's vertical hell, also known as Tower Hell.

You may think that this actually is fine, it is not nested, it is how we usually write our code, it is from top to down, so what makes it a hell?

The reason is that it creates a mutable variable for each Promise.
This is because try catch block are not an expression. It cannot be evaluated into a value. It is not declarative.

The same reason why functional language use recursion instead of for loop because for loop is not an expression and has to rely on either mutability or side effect to be useful.

So what is wrong with mutable variables?

They are difficult to track and debug, requiring extra effort to determine when or where we mutate them.

Summarizing 4 Hells

Promise's Callback Hell
❌ hard to reason with because of nesting

Promise's Bridge Hell
✅ can read the code from left to right
❌ hard to reason with because of long method chaining

Async Await's Scope Hell
❌ hard to reason with because of nesting

Async Await's Tower Hell
✅ can read the code from top to down
❌ hard to reason with because of mutable variables

Transforming Hells into Heavens

So can we create something that is:
✅ not nested
✅ no long chain
✅ no mutable variable
✅ read the code from top to down
✅ easy to use

Due to our habit we choose to read the code from top to down rather than from left to right

This is possible by fixing existing hells.

Strategy:

  1. Select hells to fix
  2. Derive strategy to fix hells

Which Hells To Keep?

The answer is to keep Promise's Bridge Hell and Async Await's Tower Hell.

This is because nesting is a generic code style issue that is not limited to Promise and Async Await. So we need to completely abandon Promise's Callback Hell and Async Await's Scope Hell.

How To Fix Them?

Let us take a closer look at Promise's Bridge Hell and Async Await's Scope Hell.

Promise's Bridge Hell
✅ does not suffer from mutable variables
❌ Suffer from long method chaining

Async Await's Tower Hell
✅ does not suffer from long method chaining
❌ Suffer from mutable variables

At this point, it becomes clear that since we can mix Promise and Async Await, we can:

  1. use Promise to create immutable variables
  2. use Async Await to slice long chaining.

Heavenly Solution!

const abc = async () => {
    const { data, error } = await fetchOne()
        .then(data => ({ data }))
        .catch(error => ({ error }))

    if (error) {
        // handle error
        return
    }

    const { data2, error2 } = await fetchTwo(data)
        .then(data2 => ({ data2 }))
        .catch(error2 => ({ error2 }))

    if (error2) {
        // handle error
        return
    }

    const { data3, error3 } = await fetchThree(data2)
        .then(data3 => ({ data3 }))
        .catch(error3 => ({ error3 }))

    if (error3) {
        // handle error
    }
    // handle data3
}
Enter fullscreen mode Exit fullscreen mode

What we need to do is await Promise and return { data } object from .then methods and { error } object from .catch methods.

Let us review the benefits:

✅ no nesting
✅ no long chain
✅ no mutable variable
✅ can read the code from top to down
✅ easy to use

Final Thoughts

Since Promise and Async Await do the same thing and conceptually similar but structurally different in terms of strengths and weakness, their relationship is actually symbiosis rather than competitive.

I hope this article inspire you to write better Promise and Async Await code.

Stay creative and innovative fellow programmers!

Top comments (1)

Collapse
 
gregfenton profile image
gregfenton

The issue pointed out at the beginning of this article is that people make mistakes trying to use one of the approaches for Promises.

And the summary of the article is: use both approaches together.

IMO, the "Scope Hell" and "Tower Hell" are rarely a problem. That is because most people do not try to nest catch semantics down to individual API call scope. It can be done, but rarely is. This is not because of async/await itself, but because absent of asynchronous calls, people do not tend to nest try/catch in standard coding approaches.

However, the "Callback Hell" and "Bridge Hell" do indeed occur. And I argue that a fundamental driver of this is that many people (junior and intermediate developers) are not familiar nor comfortable with asynchronous code behaviours nor working with Promises. So they tend to do "boilerplate" approaches when working with this code, which often means slapping both .then() and .catch() onto each individual call, simply because "that's how they've seen it done elsewhere".

Async/await doesn't fix everything about working in JS with asynchronous code, but it does enable semantics learned in many other programming languages.

I encourage code that is:

  • consistently written across files, repos and developers
  • minimal/succinct without being obtuse/obscure
  • accessible to a diverse team