Grokking Promises in JavaScript

I find Promise in JavaScript complicated to work with.

I understand how they work, and I know how to use them, but they are not ‘smooth’ for me.

Contrast that to concepts like, say, ‘calling a function’ or ‘scope’.

Those are also complex things, but while I am merrily coding away, I rely on a simple mental model, and I can blindly use those concepts without needing to think deeply about them and the complexities involved.

While coding, whenever I need to use a Promise, I invariably find myself coming to a grinding halt, getting out of ‘the zone’, and having to spend thinking time to make sure I grok my own code.

I think I now figured out why Promise rubs me the wrong way, and how to remedy that by using a mental model I am calling coderstate.

Coderstate is not something I use to code; instead it is a ‘mental state’ in my coder brain to track what I can and cannot do at some point in the code.

I currently distinguish between the following coderstates: global, module, async, function, procedure, promisor, executor, resolver.

Mentally keeping track of the coderstate helps me know when or why to use return and how to ‘pass on’ data.

Introduction

JavaScript is a language built with asynchronous operations at its heart, primarily handled through Promises and the newer async/await syntax.

While async/await simplifies asynchronous code, understanding the fundamental mechanism of Promises is crucial for any JavaScript developer looking to write efficient, performant, and scalable applications.

Coderstate

To demystify Promise, and help me work with them, I introduced a concept that I call coderstate.

Coderstates map to distinct behavioral zones within Promise handling in JavaScript, each with specific roles and expectations.

It is an attempt to give me an easy ‘mental model’ that I can rely on when working on code with Promise.

First some pseudocode with annotated areas to exemplify the coderstate.

As I read through code like the pseudocode below, I now mentally keep track of the coderstate. It helps me figure out ‘where I am at’, and how to handle data at that point in the code.

Two important aspects are:
– should or shouldn’t I use return?
– how should I ‘pass on’ the data I have?

Look for comments // ** coderstate: ...:

// ** coderstate: global or module

let apiEndpoint = "https://api.example.com";

function appendX(s) {

// ** coderstate: function

    return s + "X";
}

function logIt(s) {

// ** coderstate: procedure

    console.log(s);
}

async function manageUserData(userId) {

// ** coderstate: async

// In coderstate async, I can use "await" keywords
    let userData;
    try {
        userData = await getUserData(userId);
        console.log("User Data Processed:", userData);
    } catch (error) {
        console.error("Error handling user data:", error);
    }

    return userData;
}

function getUserData(userId) {

// ** coderstate: promisor

// A promisor is akin to an async function, 
// but does not have the async keyword in 
// the declaration. It still aims to return
// a Promise and works like an async function 
// for all intents and purposes

    return new Promise((resolve, reject) => {

// ** coderstate: executor

// Nested inside this promisor, 
// we find this executor coderstate 

        fetchData(userId, resolve, reject);
    });
}

function fetchData(userId, resolve, reject) {

// ** coderstate: executor

// This section is also part of the executor:
// the ultimate goal is to call the 
// resolve or reject functions, 
// either now, or some time in the future

    console.log("Fetching data for user ID:", userId);

    fetch(`${apiEndpoint}/user/${userId}`)
        .then(response => response.json())
        .then(

            (data) => {

// ** coderstate: resolver

// Here we're inside the function that is 
// called when the Promise resolves

// We can return plain values or we can return 
// a chained Promise. We can also call and return 
// the values of the outer reject or resolve functions

                if (data.error) {            
                    return reject(
                      "Failed to fetch data: " + 
                      data.error);
                } else {
                    return resolve(processData(data));
                }
            },

            (reason) => {

// ** coderstate: resolver

// Here we're inside the function that is called 
// when the promise rejects, which is a form of 
// resolution too
            }

        })
        .catch(
            (reason) => {

// ** coderstate: resolver

                return reject(
                    "Network error: " + 
                    error);
            }
        );
}

function processData(data) {

// ** coderstate: function 

    console.log("Processing data...");

    return data;
}

manageUserData(12345);

coderstate: global or module

Top-level code in a script or module.

Can declare variables or functions and initiate asynchronous operations.

coderstate: function

Inside a regular function; using return is expected to return some value. Returns an implicit undefined if there is no return.

coderstate: procedure

Inside a regular function; no return is expected – i.e. the caller will ignore the return value.

coderstate: async

Inside an async function.

Can use await to pause function execution until a Promise resolves, simplifying the handling of asynchronous operations.

Whatever we return will become a Promise once it is received by the caller.

coderstate: promisor

This state signifies a standard, non-async function that aims to return a Promise.

These promisor functions can be called by asynchronous code – to the caller they look like async function.

It’s all about how you can look at some code – promisors are not a ‘programming thing’, more like an ‘understanding/expecting thing’.

Promisors behave pretty much the same as async functions and can be called with await.

If the return value of a Promisor is not a Promise, the await-ing code will automatically wrap it with a resolved Promise.

We cannot use await inside the promisor because the function is not explicitly declared as async – we need to chain promises with then.

function fetchUserData(userId) {

  // ** coderstate: promisor

  return new Promise((resolve, reject) => {

    // ** coderstate: executor

    if (userId < 0) {
        // Here we use 'return' to abort the execution
        // of the executor function.
        // Without 'return', we would also execute the
        // resolve call further down
        return reject(new Error("Invalid user ID"));
    }
    resolve("User data for " + userId);

  });
}

coderstate: executor

An executor is a function passed in to the constructor of a new Promise object.

It accepts a resolve and a reject parameter, both of which are functions.

An executor is a coderstate that has access to either the resolve or the reject functions and whose job it is to eventually call resolve or reject.

When calling a nested function from an executor, where we also gets provide the resolve and/or reject parameters, I will also consider the scope of this nested function to also be in the executor coderstate. See fetchData in the snippet below for an example.

const promise = new Promise(
     (resolve, reject) => {

// ** coderstate: executor

        fetchData(userId, resolve, reject);
     }
);

function fetchData(userId, resolve, reject) {

// ** coderstate: executor

// fetchData is considered part of the executor 
// coderstate because it can directly call resolve or 
// call reject.

    fetch('https://api.example.com/data')
        .then(response => resolve(response.json()))
        .catch(error => {

// ** coderstate: resolver

            return reject(new Error("Network error: " + error.message));
        });

});

Be careful with return: a return statement can be used inside an executor, but it is not used to return any useful data to a caller.

return can only be used to force an early exit from the executor function.

From within an executor, any result data is ‘passed on’ by way of parameters when calling resolve or reject, not by way of return.

I have a more extensive code sample further down to clarify this.

In a good executor, we need to make sure all code paths eventually end in calling either resolve or calling reject.

Note that a common pattern is to use return reject(...) or return resolve(...).

This can be slightly confusing. It is important to understand that in coderstate executor, data is passed on by way of the parameter values of these function calls.

The return statement merely forces an early exit from the executor code flow and the caller will not use any of the returned data.

Contrast this with coderstate resolver where using the return statement is crucial to pass on the data.

coderstate: resolver

This state is when we’re executing the resolve or reject call from a Promise.

Data is passed in as parameters to the resolver function.

Data can be ‘passed on’ by way of the return statement.

This is important: in a coderstate resolver, the return statement is instrumental in passing on data, whereas in coderstate executor, the return statement plays no role in passing on any data.

From coderstate resolver, we can chain on additional Promise. We can either return the final ‘plain’ value or we return a chained Promise.

More Complicated Example

In this example, pay attention to when return is needed or not.

In this example we have a fast flurry of multiple coderstates, and knowing which is which can help us understand when we need return and when we can omit it.

function appendX(s,m) {
    
// ** coderstate: promisor

    return new Promise(
        (resolve, reject) => {

// ** coderstate: executor

            if (m == 1) {

// Note: Data is passed on via resolve(). No need for "return"
// We still could use "return" to force early return from code 
// and avoid trailing code execution, but any data returned is ignored

                resolve(s + ":m=" + m);
            }
            else {

                setTimeout(
                    () => { 

// ** coderstate: procedure

// No return value is expected here, so I see this as a procedure
// Note: Data is 'passed on' via resolve(). No need for "return"

                        resolve(s + ":m=" + m),
                    },
                    1000);
            }
        }
    );
}

function nested(s, m) {

// ** coderstate: promisor

// We don't see an explicit new Promise() here, but because appendX
// is either a promisor or async, this function also becomes a promisor.

    return appendX(s, m).then(
        (result) => {
// ** coderstate: resolver
// Here, the "return" statement is required to pass on the data     
            return result + "Chained";
        }
    )    
}

await nested("xx",1);

Comparing Promises with async/await

While async/await is syntactically easier and cleaner, using Promises directly gives developers finer control over asynchronous sequences, particularly when handling multiple concurrent operations or complex error handling scenarios.

Conclusion

Understanding and utilizing the different “coderstates” of Promise-related code in JavaScript can make it easier to follow the logic of async JavaScript code.

If you find this helpful, make sure to give me a positive reaction on LinkedIn! I don’t use any other social media platforms. My LinkedIn account is here:

https://www.linkedin.com/in/kristiaan