February 11, 2020

Part II: Promise/A+ spec implementation

â­  BACK TO POSTS

This is the second part of "Promise/A+ implementation" series. If you don't know what it's all about, check out the first part here. Today we're going to make our skeleton of a promise comply to 2.1 and 2.2 part of the spec.

State transitions

According to the 2.1 part of spec we need to make sure that once promise is settled, it must not transition to any other state. Let's make our _transition method handle this requirement.

_transition = (state, value) => {
// Don't transition if the promise is settled
if (this.state !== states.pending) return;
// Continue if it's not
this.state = state;
this.value = value;
}

Then method

Entirety of this section is dedicated to implementing famous then method. It makes it possible to compose asynchronous functions. It takes in two functions - onFulfilled and onRejected, none of which is required.

// Then can be called multiple times on the same Promise
const p = HelloPromise.resolve(5);
p.then(x => x*5).then(console.log);
p.then(x => x*2).then(console.log);
// logs 25 and 10

Seeing the above example it becomes pretty obvious that we have to somehow track thens that have been called on our promise object. But how? I propose that we use an array, called callbacks in which we will hold onFulfilled and onRejected references. Each item of callback array will look like this:

let callback = {
onFulfilled: [Function],
onRejected: [Function]
}

We need some place to store our callbacks. Let's add a callback array to our constructor.

class HelloPromise {
constructor(executor) {
// ...
this.callbacks = [];
// ...
}
// ...
}

It would be nice to have a separate function for adding callbacks(more on this later):

_addCallback = callback => {
this.callbacks.push(callback);
}

Now let's make our then method add new callback whenever it's called:

then = (onFulfilled, onRejected) => {
this._addCallback({
onFulfilled: onFulfilled,
onRejected: onRejected,
});
}

Spec 2.2.1

According to the spec, onFulfilled and onRejected functions are optional and should be ignored if they're not a function. We can handle it in a higher order function:

then = (onFulfilled, onRejected) => {
const member = fn => value => {
if (isFunction(fn)) return fn(value);
}
this._addCallback({
onFulfilled: onFulfilled,
onRejected: onRejected,
});
}

Spec 2.2.2 and 2.2.3

This part of the spec tells us that our callbacks should only be executed when the promise has settled. Depending on state, we will call a different function. How to achieve this behaviour? We need to add a new method to our class - _executeCallbacks.

_executeCallbacks = () => {
// if isn't settled, don't execute callbacks
if (this.state === states.pending) return;
// depending on state we execute a different method
const member = this.state === states.resolved ? 'onFulfilled' : 'onRejected';
while (this.callbacks.length) {
// execute only once
const callback = this.callbacks.shift();
// with the value the promise settled with
callback[member](this.value);
}
}

Obviously we should fire callbacks on state transition:

_transition = (state, value) => {
if (this.state !== states.pending) return;
this.state = state;
this.value = value;
// execute callbacks when promise has settled
this._executeCallbacks();
}

There's also a possibility that current Promises state has changed when we were adding a new callback. Let's fire our callbacks in _addCallback.

_addCallback = callback => {
this.callbacks.push(callback);
// Promise might have already settled
this._executeCallbacks();
}

There's one problem though. What happens if we run our test suite right now? Among others, we fail at this test:

2.2.2.2: it must not be called before promise is fulfilled

(failed) fulfilled after a delay

How to solve this problem? In fact, we should execute our callbacks asynchronously. Let's change our _executeCallbacks method.

_executeCallbacks = () => {
if (this.state === states.pending) return;
const member = this.state === states.resolved ? 'onFulfilled' : 'onRejected';
const fire = () => {
while (this.callbacks.length) {
const callback = this.callbacks.shift();
callback[member](this.value);
}
}
// execute callbacks asynchronously
runAsync(fire);
}

Second Promise

Alright now we have to address the elephant in the room. What does then method have to do with promises? 2.2.7 of the Spec says that then should return a promise. Let's call it promise2.

If onFulfilled is not a function, we should simply resolve promise2 with the value passed in.

If onRejected is not a function, we should reject promise2 with the reason passed in.

Otherwise we try to resolve promise2 with the result of onFulfilled or onRejected functions(depends what was called in _executeCallbacks).

If any error comes up we reject promise2 with this error.

Looking at the above requirements the most obvious thing is that we have to be able to somehow transition the state of the second promise. We could directly modify it's state by calling _transition method.

I, however, really don't like directly calling methods that are not meant to be accessed outside of an object.

I have to admit - this is the part that initially gave me a headache. It's like Inception, but I don't like it. There are many ways to deal with it. I will present the one I came up with.

Inception

Let's take it step by step. First, make our then method return a promise.

then = (onFulfilled, onRejected) => {
...
return new HelloPromise((resolve, reject) => null);
}

From now on we will access internal methods of promise2 from the inside of executor function, so we need to move everything inside.

then = (onFulfilled, onRejected) => {
return new HelloPromise((resolve, reject) => {
const member = fn => value => { ... }
return this._addCallback({
onFulfilled: member(onFulfilled),
onRejected: member(onRejected),
})
});
}

Spec 2.2.7.1

Resolve promise2 with the value returned from our onFulfilled or onRejected.

then = (onFulfilled, onRejected) => {
return new HelloPromise((resolve, reject) => {
const member = fn => value => {
// 2.2.7.1
resolve(fn(value))
}
return this._addCallback({
onFulfilled: member(onFulfilled),
onRejected: member(onRejected),
})
});
}

Spec 2.2.7.2

If resolving caused an error, reject promise2 with the error.

then = (onFulfilled, onRejected) => {
return new HelloPromise((resolve, reject) => {
const member = fn => value => {
try {
resolve(fn(value)); // 2.2.7.1
}
catch (reason) {
reject(reason); // 2.2.7.2
}
}
return this._addCallback({
onFulfilled: member(onFulfilled),
onRejected: member(onRejected),
})
});
}

Spec 2.2.7.3 and 2.2.7.4

If onFulfilled is not a function, resolve with the value passed in. If onRejected is not a function, reject with the value passed in.

then = (onFulfilled, onRejected) => {
return new HelloPromise((resolve, reject) => {
const member = (fn, fallback) => value => {
// 2.2.7.3 and 2.2.7.4
// fallback will be either reject or resolve
if (!isFunction(fn)) return fallback(value);
try {
resolve(fn(value)); // 2.2.7.1
}
catch (reason) {
reject(reason); // 2.2.7.2
}
}
return this._addCallback({
onFulfilled: member(onFulfilled, resolve), // fallback = resolve
onRejected: member(onRejected, reject), // fallback = reject
})
});
}

Test run!

Build and run the tests:

$ npm run build; npm test

See? HelloPromise is now compliant with 2.1 and 2.2 part of the Promises/A+ spec. The only thing left is to properly implement our _resolver method. Don't worry, then method is the most confusing part of this implementation, now our brains can finally rest.

Source code

Source code for each part of this series is available here.

Previous part

Part I: Promise/A+ Implementation

Next part

Coming soon ...