Deep JavaScript
Please support this book: buy it or donate
(Ad, please don’t block.)

16 Exploring Promises by implementing them



  Required knowledge: Promises

For this chapter, you should be roughly familiar with Promises, but much relevant knowledge is also reviewed here. If necessary, you can read the chapter on Promises in “JavaScript for impatient programmers”.

In this chapter, we will approach Promises from a different angle: Instead of using this API, we will create a simple implementation of it. This different angle once helped me greatly with making sense of Promises.

The Promise implementation is the class ToyPromise. In order to be easier to understand, it doesn’t completely match the API. But it is close enough to still give us much insight into how Promises work.

  Repository with code

ToyPromise is available on GitHub, in the repository toy-promise.

16.1 Refresher: states of Promises

Figure 2: The states of a Promise (simplified version): A Promise is initially pending. If we resolve it, it becomes fulfilled. If we reject it, it becomes rejected.

We start with a simplified version of how Promise states work (fig. 2):

16.2 Version 1: Stand-alone Promise

Our first implementation is a stand-alone Promise with minimal functionality:

ToyPromise1 is a class with three prototype methods:

That is, resolve and reject are methods (and not functions handed to a callback parameter of the constructor).

This is how this first implementation is used:

// .resolve() before .then()
const tp1 = new ToyPromise1();
tp1.resolve('abc');
tp1.then((value) => {
  assert.equal(value, 'abc');
});
// .then() before .resolve()
const tp2 = new ToyPromise1();
tp2.then((value) => {
  assert.equal(value, 'def');
});
tp2.resolve('def');

Fig. 3 illustrates how our first ToyPromise works.

  The diagrams of the data flow in Promises are optional

The motivation for the diagrams is to have a visual explanation for how Promises work. But they are optional. If you find them confusing, you can ignore them and focus on the code.

Figure 3: ToyPromise1: If a Promise is resolved, the provided value is passed on to the fulfillment reactions (first arguments of .then()). If a Promise is rejected, the provided value is passed on to the rejection reactions (second arguments of .then()).

16.2.1 Method .then()

Let’s examine .then() first. It has to handle two cases:

then(onFulfilled, onRejected) {
  const fulfillmentTask = () => {
    if (typeof onFulfilled === 'function') {
      onFulfilled(this._promiseResult);
    }
  };
  const rejectionTask = () => {
    if (typeof onRejected === 'function') {
      onRejected(this._promiseResult);
    }
  };
  switch (this._promiseState) {
    case 'pending':
      this._fulfillmentTasks.push(fulfillmentTask);
      this._rejectionTasks.push(rejectionTask);
      break;
    case 'fulfilled':
      addToTaskQueue(fulfillmentTask);
      break;
    case 'rejected':
      addToTaskQueue(rejectionTask);
      break;
    default:
      throw new Error();
  }
}

The previous code snippet uses the following helper function:

function addToTaskQueue(task) {
  setTimeout(task, 0);
}

Promises must always settle asynchronously. That’s why we don’t directly execute tasks, we add them to the task queue of the event loop (of browsers, Node.js, etc.). Note that the real Promise API doesn’t use normal tasks (like setTimeout()), it uses microtasks, which are tightly coupled with the current normal task and always execute directly after it.

16.2.2 Method .resolve()

.resolve() works as follows: If the Promise is already settled, it does nothing (ensuring that a Promise can only be settled once). Otherwise, the state of the Promise changes to 'fulfilled' and the result is cached in this.promiseResult. Next, all fulfillment reactions that have been enqueued so far, are invoked.

resolve(value) {
  if (this._promiseState !== 'pending') return this;
  this._promiseState = 'fulfilled';
  this._promiseResult = value;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
  return this; // enable chaining
}
_clearAndEnqueueTasks(tasks) {
  this._fulfillmentTasks = undefined;
  this._rejectionTasks = undefined;
  tasks.map(addToTaskQueue);
}

reject() is similar to resolve().

16.3 Version 2: Chaining .then() calls

Figure 4: ToyPromise2 chains .then() calls: .then() now returns a Promise that is resolved by whatever value is returned by the fulfillment reaction or the rejection reaction.

The next feature we implement is chaining (fig. 4): A value that we return from a fulfillment reaction or a rejection reaction can be handled by a fulfilment reaction in a following .then() call. (In the next version, chaining will become much more useful, due to special support for returning Promises.)

In the following example:

new ToyPromise2()
  .resolve('result1')
  .then(x => {
    assert.equal(x, 'result1');
    return 'result2';
  })
  .then(x => {
    assert.equal(x, 'result2');
  });

In the following example:

new ToyPromise2()
  .reject('error1')
  .then(null,
    x => {
      assert.equal(x, 'error1');
      return 'result2';
    })
  .then(x => {
    assert.equal(x, 'result2');
  });

16.4 Convenience method .catch()

The new version introduces a convenience method .catch() that makes it easier to only provide a rejection reaction. Note that only providing a fulfillment reaction is already easy – we simply omit the second parameter of .then() (see previous example).

The previous example looks nicer if we use it (line A):

new ToyPromise2()
  .reject('error1')
  .catch(x => { // (A)
    assert.equal(x, 'error1');
    return 'result2';
  })
  .then(x => {
    assert.equal(x, 'result2');
  });

The following two method invocations are equivalent:

.catch(rejectionReaction)
.then(null, rejectionReaction)

This is how .catch() is implemented:

catch(onRejected) { // [new]
  return this.then(null, onRejected);
}

16.5 Omitting reactions

The new version also forwards fulfillments if we omit a fulfillment reaction and it forwards rejections if we omit a rejection reaction. Why is that useful?

The following example demonstrates passing on rejections:

someAsyncFunction()
  .then(fulfillmentReaction1)
  .then(fulfillmentReaction2)
  .catch(rejectionReaction);

rejectionReaction can now handle the rejections of someAsyncFunction(), fulfillmentReaction1, and fulfillmentReaction2.

The following example demonstrates passing on fulfillments:

someAsyncFunction()
  .catch(rejectionReaction)
  .then(fulfillmentReaction);

If someAsyncFunction() rejects its Promise, rejectionReaction can fix whatever is wrong and return a fulfillment value that is then handled by fulfillmentReaction.

If someAsyncFunction() fulfills its Promise, fulfillmentReaction can also handle it because the .catch() is skipped.

16.6 The implementation

How is all of this handled under the hood?

Only .then() changes:

then(onFulfilled, onRejected) {
  const resultPromise = new ToyPromise2(); // [new]

  const fulfillmentTask = () => {
    if (typeof onFulfilled === 'function') {
      const returned = onFulfilled(this._promiseResult);
      resultPromise.resolve(returned); // [new]
    } else { // [new]
      // `onFulfilled` is missing
      // => we must pass on the fulfillment value
      resultPromise.resolve(this._promiseResult);
    }  
  };

  const rejectionTask = () => {
    if (typeof onRejected === 'function') {
      const returned = onRejected(this._promiseResult);
      resultPromise.resolve(returned); // [new]
    } else { // [new]
      // `onRejected` is missing
      // => we must pass on the rejection value
      resultPromise.reject(this._promiseResult);
    }
  };

  ···

  return resultPromise; // [new]
}

.then() creates and returns a new Promise (first line and last line of the method). Additionally:

16.7 Version 3: Flattening Promises returned from .then() callbacks

16.7.1 Returning Promises from a callback of .then()

Promise-flattening is mostly about making chaining more convenient: If we want to pass on a value from one .then() callback to the next one, we return it in the former. After that, .then() puts it into the Promise that it has already returned.

This approach becomes inconvenient if we return a Promise from a .then() callback. For example, the result of a Promise-based function (line A):

asyncFunc1()
.then((result1) => {
  assert.equal(result1, 'Result of asyncFunc1()');
  return asyncFunc2(); // (A)
})
.then((result2Promise) => {
  result2Promise
  .then((result2) => { // (B)
    assert.equal(
      result2, 'Result of asyncFunc2()');
  });
});

This time, putting the value returned in line A into the Promise returned by .then() forces us to unwrap that Promise in line B. It would be nice if instead, the Promise returned in line A replaced the Promise returned by .then(). How exactly that could be done is not immediately clear, but if it worked, it would let us write our code like this:

asyncFunc1()
.then((result1) => {
  assert.equal(result1, 'Result of asyncFunc1()');
  return asyncFunc2(); // (A)
})
.then((result2) => {
  // result2 is the fulfillment value, not the Promise
  assert.equal(
    result2, 'Result of asyncFunc2()');
});

In line A, we returned a Promise. Thanks to Promise-flattening, result2 is the fulfillment value of that Promise, not the Promise itself.

16.7.2 Flattening makes Promise states more complicated

  Flattening Promises in the ECMAScript specification

In the ECMAScript specification, the details of flattening Promises are described in section “Promise Objects”.

How does the Promise API handle flattening?

If a Promise P is resolved with a Promise Q, then P does not wrap Q, P “becomes” Q: State and settlement value of P are now always the same as Q’s. That helps us with .then() because .then() resolves the Promise it returns with the value returned by one of its callbacks.

How does P become Q? By locking in on Q: P becomes externally unresolvable and a settlement of Q triggers a settlement of P. Lock-in is an additional invisible Promise state that makes states more complicated.

The Promise API has one additional feature: Q doesn’t have to be a Promise, only a so-called thenable. A thenable is an object with a method .then(). The reason for this added flexibility is to enable different Promise implementations to work together (which mattered when Promises were first added to the language).

Fig. 5 visualizes the new states.

Figure 5: All states of a Promise: Promise-flattening introduces the invisible pseudo-state “locked-in”. That state is reached if a Promise P is resolved with a thenable Q. Afterwards, state and settlement value of P is always the same as those of Q.

Note that the concept of resolving has also become more complicated. Resolving a Promise now only means that it can’t be settled directly, anymore:

The ECMAScript specification puts it this way: “An unresolved Promise is always in the pending state. A resolved Promise may be pending, fulfilled, or rejected.”

16.7.3 Implementing Promise-flattening

Fig. 6 shows how ToyPromise3 handles flattening.

Figure 6: ToyPromise3 flattens resolved Promises: If the first Promise is resolved with a thenable x1, it locks in on x1 and is settled with the settlement value of x1. If the first Promise is resolved with a non-thenable value, everything works as it did before.

We detect thenables via this function:

function isThenable(value) { // [new]
  return typeof value === 'object' && value !== null
    && typeof value.then === 'function';
}

To implement lock-in, we introduce a new boolean flag ._alreadyResolved. Setting it to true deactivates .resolve() and .reject() – for example:

resolve(value) { // [new]
  if (this._alreadyResolved) return this;
  this._alreadyResolved = true;

  if (isThenable(value)) {
    // Forward fulfillments and rejections from `value` to `this`.
    // The callbacks are always executed asynchronously
    value.then(
      (result) => this._doFulfill(result),
      (error) => this._doReject(error));
  } else {
    this._doFulfill(value);
  }

  return this; // enable chaining
}

If value is a thenable then we lock the current Promise in on it:

The settling is performed via the private methods ._doFulfill() and ._doReject(), to get around the protection via ._alreadyResolved.

._doFulfill() is relatively simple:

_doFulfill(value) { // [new]
  assert.ok(!isThenable(value));
  this._promiseState = 'fulfilled';
  this._promiseResult = value;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
}

.reject() is not shown here. Its only new functionality is that it now also obeys ._alreadyResolved.

16.8 Version 4: Exceptions thrown in reaction callbacks

Figure 7: ToyPromise4 converts exceptions in Promise reactions to rejections of the Promise returned by .then().

As our final feature, we’d like our Promises to handle exceptions in user code as rejections (fig. 7). In this chapter, “user code” means the two callback parameters of .then().

new ToyPromise4()
  .resolve('a')
  .then((value) => {
    assert.equal(value, 'a');
    throw 'b'; // triggers a rejection
  })
  .catch((error) => {
    assert.equal(error, 'b');
  })

.then() now runs the Promise reactions onFulfilled and onRejected safely, via the helper method ._runReactionSafely() – for example:

  const fulfillmentTask = () => {
    if (typeof onFulfilled === 'function') {
      this._runReactionSafely(resultPromise, onFulfilled); // [new]
    } else {
      // `onFulfilled` is missing
      // => we must pass on the fulfillment value
      resultPromise.resolve(this._promiseResult);
    }  
  };

._runReactionSafely() is implemented as follows:

_runReactionSafely(resultPromise, reaction) { // [new]
  try {
    const returned = reaction(this._promiseResult);
    resultPromise.resolve(returned);
  } catch (e) {
    resultPromise.reject(e);
  }
}

16.9 Version 5: Revealing constructor pattern

We are skipping one last step: If we wanted to turn ToyPromise into an actual Promise implementation, we’d still need to implement the revealing constructor pattern: JavaScript Promises are not resolved and rejected via methods, but via functions that are handed to the executor, the callback parameter of the constructor.

const promise = new Promise(
  (resolve, reject) => { // executor
    // ···
  });

If the executor throws an exception, then promise is rejected.