Promise Fundamentals (part 2)

This is the second part of our deep dive into how to implement our promises. In the first part, we explored how to set up a basic "then"able promise.

Not stackable

We still needed to cover some aspects in our initial dive into promises and how they work behind the scenes. For example, if you create a promise, you should be able to add as many "then" hooks as possible to the same promise. As it sits, our current example does not support this.

const promise = new PromiseKeeper(resolve => {
  setTimeout(() => {
    resolve(true);
  }, 2000);
});

promise.then(res => {
  console.log(`First then fired ${res}`);
  return 2;
}).then(res => {
  console.log(`Second then fired ${res}`);
});

// This will fail
promise.then(res => { ...})

We need to make a slight adjustment to our class to fix this. Currently, we are only tracking one trackable callback. However, we can fix this by tracking our thenables in an array. The following will allow the above code to work:

class PromiseKeeper {
  constructor(executor) {
    this.state = 'pending',
    this.callbacks = [];
    const resolve = value => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      for (const cb of this.callbacks) {
        cb.onFullfilled(this.value);
       }
    };
    executor(resolve);
  }

  then(cb) {
    return new PromiseKeeper(resolve => {
      const handleCallback = () => {
        const result = cb(this.value);
        resolve(result);
      }
      if (this.state == 'pending') {
        this.callbacks.push({
          onFullfilled: () => handleCallback()
        })
      } else {
        handleCallback();
      }
    });
  }
};

With the above adjustment, we can now attach a "then" callback to our promise as many times as needed. Whenever we create a "then" function, our implementation pushes it into our callbacks array. When the promise state is "fulfilled," it will call all tracked callbacks.

Exception handling

Promises also have a special method called "catch," which fires whenever an exception occurs. To enable this, we will need to introduce a "reject" function and a method named "catch" to which a promise can add a hook for rejections. The first thing we should create to support exception handling is to write the reject function. The reject function will be similar to our resolve function but will set the promise state to "rejected" and call a separate callback: "onRejected."

const reject = value => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.value = value;
      for (const cb of this.callbacks) {
        cb.onRejected(value);
      }
    }

We also need to adjust our then method to handle the rejected case. The notable change is that handleCallback needs to inspect the state of the promise to either call onFulfilled or onRejected. Further, we should wrap the execution of the callback to catch any exception and call "reject" if needed.

then(onFulfilled, onRejected) {
    return new PromiseKeeper((resolve, reject) => {
      const handleCallback = () => {
        try {
          if (this.state == 'fulfilled') {
            const result = onFulfilled ? onFulfilled(this.value): this.value
            resolve(result);
          } else if (this.state == 'rejected') {
            const result = onRejected ? onRejected(this.value) : this.value;
            resolve(result);
          }
        } catch(e) {
          reject(e);
        }
      }

      if (this.state == 'pending') {
        this.callbacks.push({
          onFullfilled: () => handleCallback(),
          onRejected: () => handleCallback()
        })
      } else {
        handleCallback();
      }
    });
  }

If a "then" function throws an exception, we can call the "reject" function, which will set the state to rejected and call the onRejected function. If we recall the pattern of promises, a catch is another callback if the previous thenable function throws or rejects. We must add a "catch" method. A catch method is really just a "then" method with only an onRejected callback. The method is the following:

catch(onRejected) {
    return this.then(null, onRejected);
  }

Finally, the last thing we should do is wrap our call to the "executor" in our constructor in a a try {} catch {} since this will allow us to catch any exceptions thrown in our initial promise and reject accordingly:

try {
      executor(resolve, reject);
    } catch(e) {
      reject(e);
    }

Conclusion

Our PromiseKeeper now supports exception handling. There is still support for finally, and some other loose ends, which we will cover in Part 3.