What is a promise?
A promise is thenable whose behavior complies with the Promises / A + specification.
A thenable is any object or function that has a then method.
What promise looks like here:
var promise = { ... then: function (onFulfilled, onRejected) { ... }, ... };
This is the only thing we know about the promise from the very beginning (excluding its behavior).
Understanding the Promises / A + Specification
The Promises / A + specification is divided into 3 main parts:
- Promise Terms
then method- Promise Resolution Procedure.
The specification does not mention how to create, execute, or reject promises.
Therefore, we will start by creating these functions:
function deferred() { ... }
Although there is no standard way to create a promise, tests require that we still show the deferred function. Therefore, we will use deferred only to create new promises:
A partial implementation of the deferred function is executed here:
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); } } }; }
NB
- The
promise object has a then property, which is currently undefined . We still need to decide what the then function should be and what other properties the promise object should have (i.e., the shape of the promise object). This decision will also affect the implementation of the fulfill and reject functions. - The functions
resolve(promise, value) and reject(promise, value) must be called only once, and if we call them, we cannot call another. Therefore, we close them by closing and guarantee that they will be called only once between them. - We introduced a new function in the definition of
deferred , the procedure for resolving promises resolve(promise, value) . The specification designates this function as [[Resolve]](promise, x) . The implementation of this function is completely dictated by the specification. Therefore, we implement it as follows.
function resolve(promise, x) {
NB
- We omitted section 2.3.2 because it is an optimization that depends on the shape of the promise object. We will return to this section at the end.
- As you can see above, the description of section 2.3.3.3 is much larger than the actual code. This is because of the smart hack
promise = deferred(promise) , which allows us to reuse the logic of the deferred function. This ensures that promise.resolve and promise.reject can only be called once between them. We just 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 }
Promise Status and then Method
We have delayed the problem of solving the form of the promise object for so long, but we cannot postpone it further, because it depends on functions performed by both fulfill and reject functions. It's time to read what the spec has to say about promise states:
A promise must be in one of three states: expected, fulfilled, or rejected.
- Pending promise:
- can go into either completed or rejected state.
- When fulfilled, the promise:
- should not go into another state.
- should have a value that should not change.
- When rejecting a promise:
- should not go into another state.
- must have a reason that should not change.
Here, “should not change” means immutable identity (ie === ), but does not mean profound immutability.
How do we know the state of the present promise? 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,
However, there is a better alternative. Since the state of a promise can be observed only through it then (i.e., depending on the state of the promise, the then method behaves differently), we can create three specialized then functions that correspond to three states:
var promise = { then: pending | fulfilled | rejected, ... }; function pending(onFulfilled, onRejected) { ... } function fulfilled(onFulfilled, onRejected) { ... } function rejected(onFulfilled, onRejected) { ... }
In addition, we need another property to hold promise data. When a promise is waiting for data, it is the onFulfilled and onRejected . When a promise is fulfilled, the data is the magnitude of the promise. When a promise is rejected, data is the reason for the promise.
When we create a new promise, the initial state is pending, and the original data is an empty queue. Therefore, we can complete the deferred function as follows:
function deferred(promise) { var call = true; promise = promise || { then: pending, data: [] }; return }
In addition, now that we know the shape of the 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 onFulfilled or onRejected you should not call it until the execution context stack contains only platform code.
Then we need to implement the functions pending , fulfilled and rejected . We'll start with the pending function, which pushes the onFulfilled and onRejected into 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 check whether onFulfilled and onRejected functions, since according to section 2.2.1 the specifications are optional arguments. If onFulfilled and onRejected are indicated, they are composed with a deferred value according to section 2.2.7.1 and section 2.2.7.2 of the specification. Otherwise, they are shorted in accordance with 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 seen in the aptly named bind function above. At the same time, our implementation of the Promises / A + specification is complete.
unit functions
If promises are monads, then they must also have a unit function. Surprisingly, promises have two single functions: one for an allowed promise and one for a rejected promise. Since tests require them anyway, we will add them to the export interface:
exports.resolved = function (data) { return { then: fulfilled, data: data }; }; exports.rejected = function (data) { return { then: rejected, data: data }; }; exports.deferred = deferred;
resolve optimization
Section 2.3.2 of the specification describes the optimization for the resolve(promise, x) function when x is defined as 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);
Please note that although the specification states “If / when x is fulfilled, fulfill the promise with the same value”, we should use resolve instead of fulfill . I am not sure if this is a mistake in the specification.
Putting it all together
The code is available as a gist . You can simply download it and run the test package:
$ npm install promises-aplus-tests -g $ promises-aplus-tests promise.js
Needless to say, all tests pass.