3

The Promises/A+ specification is one of the smallest specifications. Hence, implementing it is the best way to understand it. The following answer by Forbes Lindesay walks us through the process of implementing the Promises/A+ specification, Basic Javascript promise implementation attempt. However, when I tested it the results were not satisfactory:

✔ 109 tests passed
✘ 769 tests failed

Clearly, the Promises/A+ specification is not as easy to implement as it seems. How would you implement the specification and explain your code to a novice? Forbes Lindesay does an excellent job explaining his code but unfortunately his implementation is incorrect.

Aadit M Shah
  • 72,912
  • 30
  • 168
  • 299
  • 3
    Look, it is OK to self answer a question. But the question itself must be up to the normal quality standards. Your question is very broad and has no specific problem that needs to be solved. – JK. Mar 24 '16 at 03:35
  • 1
    @JK. Indeed. I spent more time on the answer rather than on the question. Now I need to go back and rethink the question. Playing [Jeopardy!](https://en.wikipedia.org/wiki/Jeopardy!) is harder than it seems. – Aadit M Shah Mar 24 '16 at 03:37

1 Answers1

16

What is a promise?

A promise is a thenable whose behavior conforms to the Promises/A+ specification.

A thenable is any object or function that has a then method.

Here's what a promise looks like:

var promise = {
    ...
    then: function (onFulfilled, onRejected) { ... },
    ...
};

This is the only thing we know about a promise from the outset (excluding its behavior).

Understanding the Promises/A+ specification

The Promises/A+ specification is divided into 3 main parts:

  1. Promise states
  2. The then Method
  3. The Promise Resolution Procedure

The specification does not mention how to create, fulfill or reject promises.

Hence, we'll start by creating those functions:

function deferred() { ... } // returns an object { promise, resolve, reject }

function fulfill(promise, value) { ... } // fulfills promise with value
function reject(promise, reason) { ... } // rejects promise with reason

Although there's no standard way of creating a promise yet the tests require us to expose a deferred function anyway. Hence, we'll only use deferred to create new promises:

  • deferred(): creates an object consisting of { promise, resolve, reject }:

    • promise is a promise that is currently in the pending state.
    • resolve(value) resolves the promise with value.
    • reject(reason) moves the promise from the pending state to the rejected state, with rejection reason reason.

Here's a partial implementation of the deferred function:

function deferred() {
    var call = true;

    var promise = {
        then: undefined,
        ...
    };

    return {
        promise: promise,
        resolve: function (value) {
            if (call) {
                call = false;
                resolve(promise, value);
            }
        },
        reject: function (reason) {
            if (call) {
                call = false;
                reject(promise, reason);
            }
        }
    };
}

N.B.

  1. The promise object only has a then property which is currently undefined. We still need to decide on what the then function should be and what other properties a promise object should have (i.e. the shape of a promise object). This decision will also affect the implementation of the fulfill and reject functions.
  2. The resolve(promise, value) and reject(promise, value) functions should only be callable once and if we call one then we shouldn't be able to call the other. Hence, we wrap them in a closure and ensure that they are only called once between both of them.
  3. We introduced a new function in the definition of deferred, the promise resolution procedure resolve(promise, value). The specification denotes this function as [[Resolve]](promise, x). The implementation of this function is entirely dictated by the specification. Hence, we'll implement it next.
function resolve(promise, x) {
// 2.3.1. If promise and x refer to the same object,
//        reject promise with a TypeError as the reason.
    if (x === promise) return reject(promise, new TypeError("Self resolve"));
// 2.3.4. If x is not an object or function, fulfill promise with x.
    var type = typeof x;
    if (type !== "object" && type !== "function" || x === null)
        return fulfill(promise, x);
// 2.3.3.1. Let then be x.then.
// 2.3.3.2. If retrieving the property x.then results in a thrown exception e,
//          reject promise with e as the reason.
    try {
        var then = x.then;
    } catch (e) {
        return reject(promise, e);
    }
// 2.3.3.4. If then is not a function, fulfill promise with x.
    if (typeof then !== "function") return fulfill(promise, x);
// 2.3.3.3. If then is a function, call it with x as this, first argument
//          resolvePromise, and second argument rejectPromise, where:
// 2.3.3.3.1. If/when resolvePromise is called with a value y,
//            run [[Resolve]](promise, y).
// 2.3.3.3.2. If/when rejectPromise is called with a reason r,
//            reject promise with r.
// 2.3.3.3.3. If both resolvePromise and rejectPromise are called,
//            or multiple calls to the same argument are made,
//            the first call takes precedence, and any further calls are ignored.
// 2.3.3.3.4. If calling then throws an exception e,
// 2.3.3.3.4.1. If resolvePromise or rejectPromise have been called, ignore it.
// 2.3.3.3.4.2. Otherwise, reject promise with e as the reason.
    promise = deferred(promise);
    try {
        then.call(x, promise.resolve, promise.reject);
    } catch (e) {
        promise.reject(e);
    }
}

N.B.

  1. We omitted section 2.3.2 because it's an optimization that depends upon the shape of a promise object. We'll revisit this section towards the end.
  2. As seen above, the description of section 2.3.3.3 is much longer than the actual code. This is because of the clever hack promise = deferred(promise) which allows us to reuse the logic of the deferred function. This ensures that promise.resolve and promise.reject are only callable once between both of them. We only need to make a small change to the deferred function to make this hack work.
function deferred(promise) {
    var call = true;

    promise = promise || {
        then: undefined,
        ...
    };

    return /* the same object as before */
}

Promise states and the then method

We've delayed the problem of deciding the shape of a promise object for so long but we can't delay any further because the implementations of both the fulfill and reject functions depend upon it. It's time to read what the specification has to say about promise states:

A promise must be in one of three states: pending, fulfilled, or rejected.

  1. When pending, a promise:
    1. may transition to either the fulfilled or rejected state.
  2. When fulfilled, a promise:
    1. must not transition to any other state.
    2. must have a value, which must not change.
  3. When rejected, a promise:
    1. must not transition to any other state.
    2. must have a reason, which must not change.

Here, “must not change” means immutable identity (i.e. ===), but does not imply deep immutability.

How do we know which state the promise is currently in? We could do something like this:

var PENDING   = 0;
var FULFILLED = 1;
var REJECTED  = 2;

var promise = {
    then:  function (onFulfilled, onRejected) { ... },
    state: PENDING | FULFILLED | REJECTED, // vertical bar is not bitwise or
    ...
};

However, there's a better alternative. Since the state of a promise is only observable through it's then method (i.e. depending upon the state of the promise the then method behaves differently), we can create three specialized then functions corresponding to the three states:

var promise = {
    then: pending | fulfilled | rejected,
    ...
};

function pending(onFulfilled, onRejected) { ... }
function fulfilled(onFulfilled, onRejected) { ... }
function rejected(onFulfilled, onRejected) { ... }

In addition, we need one more property to hold the data of the promise. When the promise is pending the data is a queue of onFulfilled and onRejected callbacks. When the promise is fulfilled the data is the value of the promise. When the promise is rejected the data is the reason of the promise.

When we create a new promise the initial state is pending and the initial data is an empty queue. Hence, we can complete the implementation of the deferred function as follows:

function deferred(promise) {
    var call = true;

    promise = promise || {
        then: pending,
        data: []
    };

    return /* the same object as before */
}

In addition, now that we know the shape of a promise object we can finally implement the fulfill and reject functions:

function fulfill(promise, value) {
    setTimeout(send, 0, promise.data, "onFulfilled", value);
    promise.then = fulfilled;
    promise.data = value;
}

function reject(promise, reason) {
    setTimeout(send, 0, promise.data, "onRejected", reason);
    promise.then = rejected;
    promise.data = reason;
}

function send(queue, callback, data) {
    for (var item of queue) item[callback](data);
}

We need to use setTimeout because according to section 2.2.4 of the specification onFulfilled or onRejected must not be called until the execution context stack contains only platform code.

Next, we need to implement the pending, fulfilled and rejected functions. We'll start with the pending function which pushes the onFulfilled and onRejected callbacks to the queue and returns a new promise:

function pending(onFulfilled, onRejected) {
    var future = deferred();

    this.data.push({
        onFulfilled: typeof onFulfilled === "function" ?
            compose(future, onFulfilled) : future.resolve,
        onRejected:  typeof onRejected  === "function" ?
            compose(future, onRejected)  : future.reject
    });

    return future.promise;
}

function compose(future, fun) {
    return function (data) {
        try {
            future.resolve(fun(data));
        } catch (reason) {
            future.reject(reason);
        }
    };
}

We need to test whether onFulfilled and onRejected are functions because according to section 2.2.1 of the specification they are optional arguments. If onFulfilled and onRejected are provided then they are composed with the deferred value as per section 2.2.7.1 and section 2.2.7.2 of the specification. Otherwise, they are short-circuited as per section 2.2.7.3 and section 2.2.7.4 of the specification.

Finally, we implement the fulfilled and rejected functions as follows:

function fulfilled(onFulfilled, onRejected) {
    return bind(this, onFulfilled);
}

function rejected(onFulfilled, onRejected) {
    return bind(this, onRejected);
}

function bind(promise, fun) {
    if (typeof fun !== "function") return promise;
    var future = deferred();
    setTimeout(compose(future, fun), 0, promise.data);
    return future.promise;
}

Interestingly, promises are monads as can be seen in the aptly named bind function above. With this, our implementation of the Promises/A+ specification is now complete.

Optimizing resolve

Section 2.3.2 of the specification describes an optimization for the resolve(promise, x) function when x is determined to be a promise. Here's the optimized resolve function:

function resolve(promise, x) {
    if (x === promise) return reject(promise, new TypeError("Self resolve"));

    var type = typeof x;
    if (type !== "object" && type !== "function" || x === null)
        return fulfill(promise, x);

    try {
        var then = x.then;
    } catch (e) {
        return reject(promise, e);
    }

    if (typeof then !== "function") return fulfill(promise, x);
// 2.3.2.1. If x is pending, promise must remain pending until x is
//          fulfilled or rejected.
    if (then === pending) return void x.data.push({
        onFulfilled: function (value) {
            fulfill(promise, value);
        },
        onRejected: function (reason) {
            reject(promise, reason);
        }
    });
// 2.3.2.2. If/when x is fulfilled, fulfill promise with the same value.
    if (then === fulfilled) return fulfill(promise, x.data);
// 2.3.2.3. If/when x is rejected, reject promise with the same reason.
    if (then === rejected) return reject(promise, x.data);

    promise = deferred(promise);

    try {
        then.call(x, promise.resolve, promise.reject);
    } catch (e) {
        promise.reject(e);
    }
}

Putting it all together

The code is available as a gist. You can simply download it and run the test suite:

$ npm install promises-aplus-tests -g
$ promises-aplus-tests promise.js

Needless to say, all the tests pass.

Aadit M Shah
  • 72,912
  • 30
  • 168
  • 299
  • 4
    A Herculean effort to be sure, but what is the question being answered? –  Mar 24 '16 at 04:24
  • 4
    FYI, there is a standard way to create a promise now in the [ES6 specification](http://www.ecma-international.org/ecma-262/6.0/#sec-promise-constructor) so your statement *Although there's no standard way of creating a promise yet* could be edited. – jfriend00 Mar 24 '16 at 09:58
  • Inspired from you, I write one and pass the tests: https://github.com/chaoyangnz/promise/blob/master/src/promise.js – Chao Jul 31 '18 at 13:27
  • 1
    This answer is incorrect. A promise is terminology used to represent one side of a contract between a value production and a value consumption with the other side being a producer representing over all a value which may not be guaranteed to be immediately available if at all, roughly speaking. That is a concept and Promise/A+ is one implementation of that concept. If you implement promises that fail the Promise/A+ tests but still work as promises then they are still promises. – jgmjgm Oct 14 '19 at 23:51
  • I also urge care with recommending Promise/A+. It is important in some cases if you are returning a promise intended to work with systems made compatible with Promise/A+. This is the only advantage of the specification. Among promise implementations it performs poorly and has problems that make it easy to write async code that is vulnerable to inconsistent state errors that cause security and stability problems as well as to DOS attacks. It also makes some algorithms very hard to implement that can be easily implemented without Promises/A+. They're more of a Promises/C-. – jgmjgm Oct 14 '19 at 23:57
  • What on Earth? You also asked the original question? This doesn't look right. – jgmjgm Oct 15 '19 at 07:23
  • @jgmjgm To quote the [StackOverflow FAQ](https://stackoverflow.com/help/on-topic), "It’s also perfectly fine to ask and answer your own question, as long as you pretend you’re on Jeopardy! — phrase it in the form of a question." In fact, the co-founder of StackOverflow, Jeff Atwood, [says that](https://stackoverflow.blog/2011/07/01/its-ok-to-ask-and-answer-your-own-questions/), "it is not merely OK to ask and answer your own question, it is explicitly encouraged." – Aadit M Shah Oct 15 '19 at 10:33
  • @jgmjgm No, I did not make any factually incorrect statements. If you think I did then please do tell me which statements are factually incorrect and why. I'll correct them if they are indeed incorrect. Furthermore, I'm neither mandating anything nor spreading any misinformation. If you feel that the Promises/A+ specification is wrong then take that up with the [authors](https://promisesaplus.com/credits). StackOverflow is not the place to rant about it. – Aadit M Shah Oct 15 '19 at 10:41
  • 1
    Your opening statement is incorrect, swap it around to "What are Promises/A+?" then explain that they're a promise implementation. Nike are shoes but not all shoes are Nike. – jgmjgm Oct 15 '19 at 10:47
  • That's the main one that causes a problem. The rest of the text is a write up on the Promise/A+ spec which I would assume it matches that spec. The issue with the opening question isn't just that it is incorrect. Users searching for information about promises will then mistakenly believe that for something to be a promise it has to follow that specification. When people learn that they have to unlearn it taking them two steps back. – jgmjgm Oct 15 '19 at 12:59