Implementing Custom Promise

·

8 min read

To write better control flows with promises, it is crucial to understand how a Promise works. What better way to learn than by implementing one? The final code is a modified version of the code I took from https://learnersbucket.com/.

As with any design problem, let's start by defining the API. I'll begin with a simple one, and we will build on top of this.

const promise = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(100);
  }, 100)
})

const promise2 = promise.then(data => {
  return data * 2;
}).catch(err => {
  console.log({err});
})

promise2.then(data => {
  console.log(data);
  return data;
})
  1. MyPromise should have a constructor that will take in a callback. This callback will be supplied with two methods: resolve and reject.

  2. I should be able to attach success and failure handlers to the created promise.

  3. The success and failure handlers should be chainable, and each of these chains returns a new promise.

const states = {
  PENDING: 0,
  FULFILLED: 1,
  REJECTED: 2
}
class MyPromise {
  constructor(callback) {
    this.state = states.PENDING; // the settled state of the promise
    this.value = undefined; // the settled value of the promise
    this.handlers = []; // all the handlers attached to this promise

    // its possible for synchronous callbacks to fail, hence wrap
    // it in a try..catch block
     try {
      callback(this._resolve, this._reject);
    } catch(err) {
      this._reject(err);
    }
  }

  _resolve = (value) => {
    this._handleUpdate(states.FULFILLED, value)
  }

  _reject = (value) => {
    this._handleUpdate(states.REJECTED, value)
  }
  // wip
  _handleUpdate = (state, value) => {
    this.state =  state;
    this.value = value;
    this._executeHandlers();
  }

  _executeHandlers = () => {
    // should call each of the handlers
  }
}

Onto registering handlers on a promise. The function signature for a then handler is then(onSuccess, onFailure). So there are two callbacks that are provided, and the return values must be a new promise.

Unlike promise initialization, the resolve and reject methods of the promise returned by then are not part of the user land code. This means they don't have to be exposed and must be internally managed by the then function.


class MyPromise {
  /**
  * the data structure for handler is { onSuccess: fn , onFailure: fn}
  * this way we do not have to maintain separate lists for success and failure callbacks
  */
  then = (onSuccess, onFailure) => {
    // wip
    return new MyPromise((resolve, reject) => {
      this._addHandler({
        onSuccess: (value) => {},
        onFailure: (value) => {}
      })
    })
  };

  // register the handlers to be called in the future 
  _addHandler = (handler) => {
    this.handlers.push(handler);
    // since its possible to add handlers to resolved promises, we execute handlers right away.
    // this wont affect the pending promises as we check the promise state before execution
    this._executeHandlers();
  }

  // when a promise is settled we call its handlers.
  _executeHandlers = () => {
    if (this.state === states.PENDING) {
      return;
    }
    this.handlers.forEach((handler) => {
      if (this.state === states.FULFILLED) {
        return handler.onSuccess(this.value);
      }
      return handler.onFailure(this.value);
    })
    // reset the handlers because they have been called.
    this.handlers = [];
  }
}

In the code above, we can see that then() returns a new promise. But what happens to this promise? When does it get resolved or rejected? The idea is simple. If the onSuccess callback successfully runs, resolve the promise; otherwise, reject the promise.

then = (onSuccess, onFailure) => {
    return new MyPromise((resolve, reject) => {
      this._addHandler({
        onSuccess: (value) => {
          /**
          * if `then` was called without onSuccess, simply pass down the result to the next handler 
          */
          if (!onSuccess) {
            return resolve(value);
          }
          try {
            // if onSuccess runs to completion without any errors resolve it with the return value of the callback
            return resolve(onSuccess(value));
          } catch (error) {
            reject(error);
          }
        },
        onFailure: (value) => {
          /**
          * if `then` was called without onFailure, simply propagate the error to the next error handler 
          */
          if (!onFailure) {
            return reject(value);
          }
          try {
            // since the thrown promise got handled as part of onFailure, error is not propagated further
            return resolve(onFailure(value));
          } catch (error) {
            return reject(error);
          }
        }
      })
    })
  };

  // catch works as an alias to `then` without a success handler 
  catch = (onFailure) => {
    return this.then(null, onFailure);
  };

Finally, we can talk about finally (pun intended).

  1. Just like then and catch, finally has to return a new promise because it's chainable.

  2. Regardless of the actual state of the promise, whether it got fulfilled or rejected, the provided callback must be called. This is why it's advised to do state-agnostic work in finally. For example, closing a database connection (even if the query did not yield desired results, the connection has to be closed).


  finally = (callback) => {
    return new MyPromise((resolve, reject) => {
      let wasResolved;
      let value;
      // we do not want to call the callback right away. just add it as a handler
      this.then((val) => {
        value = val;
        wasResolved = true;
        // passing downs the values for further success propagation
        resolve(value);
        return callback();
      }, (err) => {
        value = err;
        wasResolved = false;
        // passing downs the values for further success propagation
        reject(value);
        return callback();
      })
    })
  };

Here is the final code:

const states = {
  PENDING: 0,
  FULFILLED: 1,
  REJECTED: 2
}
class MyPromise {
  // initialize the promise
  constructor(callback) {
    this.state = states.PENDING; // the settled state of the promise
    this.value = undefined; // the settled value of the promise
    this.handlers = []; // all the handlers attached to this promise

    // its possible for synchronous callbacks to fail, hence wrap
    // it in a try..catch block
     try {
      callback(this._resolve, this._reject);
    } catch(err) {
      this._reject(err);
    }
  }
  // helper function for resolve
  _resolve = (value) => {
    this._handleUpdate(states.FULFILLED, value);
  }
  // helper function for reject
  _reject = (value) => {
    this._handleUpdate(states.REJECTED, value);
  }
  // handle the state change
  _handleUpdate = (state, value) => {
    if (state === states.PENDING) {
      return;
    }
    setTimeout(() => {
      if (value instanceof MyPromise) {
        value.then(this._resolve, this._reject);
      } else {
        this.state = state;
        this.value = value;
        this._executeHandlers();
      }
    }, 0)
  }
  _executeHandlers = () => {
    if (this.state === states.PENDING) {
      return;
    }
    this.handlers.forEach((handler) => {
      if (this.state === states.FULFILLED) {
        return handler.onSuccess(this.value);
      }
      return handler.onFailure(this.value);
    })
    // reset the handlers because they have been called.
    this.handlers = [];
  }

   // register the handlers to be called in the future 
   _addHandler = (handler) => {
    this.handlers.push(handler);
    // since its possible to add handlers to resolved promises, we execute handlers right away.
    // this wont affect the pending promises as we check the promise state before execution
    this._executeHandlers();
  }

  then = (onSuccess, onFailure) => {
    return new MyPromise((resolve, reject) => {
      this._addHandler({
        onSuccess: (value) => {
          /**
          * if `then` was called without onSuccess, simply pass down the result to the next handler 
          */
          if (!onSuccess) {
            return resolve(value);
          }
          try {
            // if onSuccess runs to completion without any errors resolve it with the return value of the callback
            return resolve(onSuccess(value));
          } catch (error) {
            reject(error);
          }
        },
        onFailure: (value) => {
          /**
          * if `then` was called without onFailure, simply propagate the error to the next error handler 
          */
          if (!onFailure) {
            return reject(value);
          }
          try {
            // since the thrown promise got handled as part of onFailure, error is not propagated further
            return resolve(onFailure(value));
          } catch (error) {
            return reject(error);
          }
        }
      })
    })
  };

  // catch works as an alias to `then` without a success handler 
  catch = (onFailure) => {
    return this.then(null, onFailure);
  };
  finally = (callback) => {
    return new MyPromise((resolve, reject) => {
      let wasResolved;
      let value;
      // we do not want to call the callback right away. just add it as a handler
      this.then((val) => {
        value = val;
        wasResolved = true;
        // passing downs the values for further success propagation
        resolve(value);
        return callback();
      }, (err) => {
        value = err;
        wasResolved = false;
        // passing downs the values for further success propagation
        reject(value);
        return callback();
      })
    })
  };
};

probable questions:

  1. Why is this.handlers an array if we are anyway returning a new promise from then and adding a handler to it?

    This is a bit uncommon code in enterprise applications, but we do allow multiple then chaining on the same promise, which is why it is an array. EG:

     const promise = new MyPromise((resolve, reject) => {
       setTimeout(() => {
         resolve(100);
       }, 100);
     })
     // promise.handlers length is 1
     promise.then((data) => {
       console.log({ data })
     })
     // promise.handlers length is 2 now
     promise.then((data) => {
       console.log({ data: data * 2 })
     })
    
  2. What if I resolve a promise with another promise?

    That's doable. In this case, the setting of state and value is deferred until the nested promise is resolved.

    Consider this scenario:

     const promise = new MyPromise((resolve, reject) => {
       setTimeout(() => {
         resolve(new MyPromise((resolve, reject) => {
           setTimeout(() => {
             resolve(100)
           });
         }));
       }, 100);
     })
    
     p1 = promise.then((data) => {
       return data * 2;
     });
    
     p1.then((data) => {
       console.log("woah it passed", data)
     }, (err) => {
       console.log("some error occured", err);
     })
    

    Now if you log the settled values of promise and p1 you will see 100 and 200 respectively. The code responsible for this is the if clause of _handleUpdate. It checks if the instance is a promise and attaches another handler to it so that the resolved value can be used to determine the promise. Note that the most important aspect of this implementation is the fat arrows, without which the binding will not work and this context will not point to the correct instance of promise.