Decoding promises

Posted:
12/01/2023
| By:
Aditya Gannavarapu

Many developers limit themselves to merely grasping the surface-level implementation of promises, but only a small number dig into the inner workings of the mechanism. In this article, we will dive deeply into the construction of promises—specifically custom promises—to gain a comprehensive understanding of their inner workings.

To get the most out of this article, we recommend first establishing an understanding of syntax and the working of actual promises before diving in.

Please note, this implementation is just a representation of actual promise but may miss some crucial aspects like promise.all(), promise.allSettled(), promise.race(), static resolve reject methods.

For the purpose of this blog, we are restricting our scope to:

  • State of a promise
  • Promise constructor
  • Resolve or reject
  • Then
  • ExecuteHandlers
  • Catch

States of a promise

A promise can be either pending, resolved, or rejected.

  • Pending: When the promise has not been resolved
  • Resolved: When the promise has a successful resolution
  • Rejected: When the promise is discarded

We’ll maintain the states object as a constant. If a promise is not yet resolved, it’s state.pending. Otherwise, a promise will either be state.resolved if successful or state.rejected if not.

23-CPMK-1513-BlogImage-Figure1.png

Figure 1: State a promise respects

Promise constructor

Within a promise constructor, we will define four things:

  1. State initialization: Initialize the state to pending, as the promise is neither resolved nor rejected
  2. Initialize the value: Set the initial value of the promise to null, as this would be set once a promise is resolved or rejected
  3. Appropriate handlers: Have the appropriate handlers, which would be executed in the near future asynchronously
  4. Trigger the callback executor function: Trigger the function so that the promise is initialized and the respective states are either resolved or rejected

23-CPMK-1513-BlogImage-Figure2.png

Figure 2: Constructor definition of a promise

Resolve or reject

When we trigger a promise, its value will either be resolved or rejected. Rejected and resolved promise states have already ended their lifecycle, hence why resolved and rejected are not called. A promise will only be resolved if it is currently unresolved, meaning in a pending state, and the promise is a success. Once the promise is resolved, the state is updated to resolved, and the value is set to the value passed as a parameter to the resolved function.

23-CPMK-1513-BlogImage-Figure3.png

Figure 3: Resolve is triggered when a promise is resolved

A promise will only be rejected if it’s currently unresolved in a pending state, and the promise fails. When a promise is rejected, the state is updated to rejected, and the value is set to the value passed as a parameter to the resolved or catch function.

23-CPMK-1513-BlogImage-Figure4.png

Figure 4: Reject is triggered when a promise is rejected

Then

Then is a function that continues the promise execution, and eventually leads to ultimate resolution or rejection. It should be chain-able, as a promise can in turn return another promise. Thus, then should always return a new promise instance. Then takes two parameters: one is the success callback, and the other is the failure callback.

Whenever then is triggered, a new promise is triggered, and handlers are fed with an object that takes the resolve or reject of a new promise object and passes success and failure callbacks. After passing the values to handlers, the handlers are initialized—executor function—with the constructor of the new promise.

The key point to understand here is that the onSuccess handler captures the value in its closure when it’s created, not when it’s executed. Therefore, even though the handler is part of the new promise’s context, it still has access to the value from the previous promise. So, the value of the previous promise is preserved in the closure of the onSuccess handler, allowing you to access it even when creating new promises.

23-CPMK-1513-BlogImage-Figure5.png

Figure 5: Then always returns a promise, as it should be chainable

Execute handler help

Execute handler has two flows: one when the state is resolved, and one when the state is rejected. Both flows pretty much follow the same signature, except that with a resolved state, we trigger the onSuccess if provided. Whereas with a rejected state, we trigger the onFail callback if provided.

In order to mimic asynchrony, we use setTimeout, though a zero timelapse would also work, as we just want to out this function in the macro task queue or event loop. Execute handler checks use cases such as:

  • If onSuccess/onFail is provided, it executes them with the value of the current promise
  • If onSuccess/onFail is not provided, then simply resolve/reject the new promise created in then with the current value
  • If onSuccess/onFail returns a new promise, execute that promise itself, and pass the resolved/rejected value of this promise to the intermediate promise created as part of then, which maintains chaining
  • On any exception, directly trigger the reject function of the intermediate promise created via then

23-CPMK-1513-BlogImage-Figure6.png

Figure 6: Resolved state of then

23-CPMK-1513-BlogImage-Figure7.png

Figure 7: Rejected state of then

Catch

In a promise chain, when an error occurs in any of the preceding then methods, like if an exception is thrown, the normal flow of the chain is disrupted. The promise chain jumps to the nearest catch block down the chain, skipping any remaining then blocks. The code within the catch block is executed, and it receives the error as an argument, allowing you to handle or log the error as needed. After the catch block is executed, the promise chain continues with its normal flow, meaning you can still attach more then or catch blocks after the initial catch block to handle subsequent promises.

23-CPMK-1513-BlogImage-Figure8.png

Figure 8: The label goes as functioning of the catch call

Putting it all together

const state = {
    PENDING: 'pending', 
    RESOLVED:'resolved',
    REJECTED: 'rejected'
}

class CustomPromise {
    constructor(executor){
        // executor is the callback which is initialized when a promise is created.
        this.value = null;
        this.handlers = [];
        this.state = state.PENDING;
        
        try{
            // execute the executor with resolve and rejects as params.
            executor(this.resolve.bind(this), this.reject.bind(this))
        }catch(e){
            this.reject(e)
        }
    }
    
    resolve(value){
        // only resolve when unresolved
        if(this.state ===state.PENDING){
            this.state = state.RESOLVED;
        //update value to the resolved value
            this.value = value;
            this.handlers.forEach(item => item.onSuccess(this.value))
        }
    }
    
    reject(value){
        // only reject when unresolved
        if(this.state=== state.PENDING){
            this.state = state.REJECTED;
            // update value to the rejected value
            this.value = value;
            this.handlers.forEach(item => item.onFail(this.value))
        }
    }
    
    executeHandlers(handleObj){
 //handle the resolved flow
        if(this.state === state.RESOLVED){
           var cb = handleObj.onSuccess
           // to mock asynchrony
           setTimeout(() => {
               try{
                  // check if success callback present
                   if(typeof cb === "function"){
                  // if yes then execute the cb with the existing promise resolved value
                    var res = cb(this.value)
                  // if the result is inturn returns a promise
                    if(res instanceof CustomPromise){
                  // resolve the promise and set the value to returning promise.
                        res.then(resVal => handleObj.res(resVal), resVal => handleObj.rej(resVal))
                    } else {
                        setTimeout(() => {
                    // if res is not a promise directly resolve it withe the promise create from the previous then
                            handleObj.res(res)
                        }, 0)
                    }
               }else {
// if success callback not present simply resolve the promise with the result of callback
                   handleObj.res(this.value)
               }
               }catch(e){
// on any exception trigger the reject handler.
                  handleObj.rej(e) 
               }
           })
//handle the rejected flow below
        } else if(this.state === state.REJECTED){
            // to mock asynchrony
            var cb = handleObj.onFail;
            setTimeout(() => {
                try{
                  // check if failure callback present
                    if(typeof cb === "function"){
// if yes then execute the cb with the existing promise resolved value
                        const res = cb(this.value)
// if the result is inturn returns a promise
                        if(res instanceof CustomPromise){
                            res.then(resVal => handleObj.res(resVal), resVal => handleObj.rej(resval))
                        } else {
                            setTimeout(() => {
// if res is not a promise directly reject it withe the promise create from the previous then
                                 handleObj.rej(res)
                        }, 0)
                        }
                    } else{
// if failure callback not present simply resolve the promise with the result of callback
                        handleObj.rej(this.value)
                    }
                } catch(e){
// on any exception trigger the reject handler.
                   handleObj.rej(e)   
                }
            })
        }
    }
    
    then(onSuccess, onFail){
// everytime a then is triggerred return a new promise
        return new CustomPromise((res, rej) => {
// as a part of constructor function push handlers with new resolve , reject and prev onSuccess. onFail
            this.handlers.push({
                res,
                rej,
                onSuccess,
                onFail
            })
//execute the handlers to kickstart
            this.executeHandlers(this.handlers[this.handlers.length-1])
        })
    }
    
    catch(onFail) {
// onany failure / exception gracefully handle the scenarios
        return this.then(null, onFail);
    }
}

let a = new CustomPromise((res, rej) => {
    
    res(5)
})

a.then((val) => {
    //createdNew promise with old new resolve reject and new onSuccess onFail function
    console.log(val)
    return new CustomPromise((res, rej) => {
        res(10)
    })
}).then(val => {
    console.log(val)
})

23-CPMK-1513-BlogImage-Figure9.png

Figure 9: Output for the code above

In the above example, we are resolving a promise with value 5 as a part of its constructor where resolve is triggered. Resolve sets the value as 5, and sets the state as resolved. On the first then, we return a new custom promise. However, while creating this new custom promise the internal constructor functional is executed and it executes the executeHandler function because in this case, we’re returning another custom promise.

Thus, it falls in the scenario of callback returning a custom promise, and we will execute the custom promise with a resolved value of 10. While the execution of this new custom promise occurs, we set the value to 10. So, on the next then trigger, we only trigger the new promise’s then with the previous promise value—that’s how we settle all the values.

Conclusion

While promises are no easy task, it’s crucial to understand them in relation to asynchrony. By examining these fundamental aspects of promises, you can gain valuable insights into how promises work internally. It’s also important to note that while this custom promise implementation provides a simplified view of promises, real-world promises offer additional features, such as Promise.all(), Promise.race(), and static methods such as Promise.resolve() and Promise.reject(). Nonetheless, understanding the core concepts presented here forms a solid foundation for comprehending the inner workings of promises in JavaScript.

Recommended