My objective is to write a Promises/A+ conformant implementation similar to
then/promise
. Also, I’ll do it the TDD way, where I’ll write some tests first and then implement what’s needed to make the tests pass (tests will be written on the
Jest
platform).
This article was one of the best references I found online. This implementation is heavily inspired by it. I’ll also refer to the A+ promise spec when necessary.
Promise state
A promise is an object/function that must be in one of these states: PENDING, FULFILLED, or REJECTED. Initially, the promise is in a PENDING state.
A promise can transition from a PENDING state to either a FULFILLED state with a fulfillment value or to a REJECTED state with a rejection reason.
To make the transition, the Promise constructor receives a function called executor. The executor is called immediately with two functions, fulfill and reject, that when called, perform the state transition:
fulfill(value)- fromPENDINGtoFULFILLEDwithvalue. Thevalueis now a property of the promise.reject(reason)- fromPENDINGtoREJECTEDwithreason. Thereasonis now a property of the promise.
it('receives a executor function when constructed which is called immediately', () => {
// mock function with spies
const executor = jest.fn()
const promise = new APromise(executor)
// mock function should be called immediately
expect(executor.mock.calls.length).toBe(1)
// arguments should be functions
expect(typeof executor.mock.calls[0][0]).toBe('function')
expect(typeof executor.mock.calls[0][1]).toBe('function')
})
it('is in a PENDING state', () => {
const promise = new APromise(function executor(fulfill, reject) { /* ... */ })
// for the sake of simplicity the state is public
expect(promise.state).toBe('PENDING')
})
it('transitions to the FULFILLED state with a `value`', () => {
const value = ':)'
const promise = new APromise((fulfill, reject) => {
fulfill(value)
})
expect(promise.state).toBe('FULFILLED')
})
it('transitions to the REJECTED state with a `reason`', () => {
const reason = 'I failed :('
const promise = new APromise((fulfill, reject) => {
reject(reason)
})
expect(promise.state).toBe('REJECTED')
})
The initial implementation is straightforward:
// possible states
const PENDING = 'PENDING'
const FULFILLED = 'FULFILLED'
const REJECTED = 'REJECTED'
class APromise {
constructor(executor) {
// initial state
this.state = PENDING
// the fulfillment value or rejection reason is mapped internally to `value`
// initially the promise doesn't have a value
// call the executor immediately
doResolve(this, executor)
}
}
// fulfill with `value`
function fulfill(promise, value) {
promise.state = FULFILLED
promise.value = value
}
// reject with `reason`
function reject(promise, reason) {
promise.state = REJECTED
promise.value = reason
}
// creates the fulfill/reject functions that are arguments of the executor
function doResolve(promise, executor) {
function wrapFulfill(value) {
fulfill(promise, value)
}
function wrapReject(reason) {
reject(promise, reason)
}
executor(wrapFulfill, wrapReject)
}
Observing state changes
To observe changes in the state of the promise (and the fulfillment value or rejection reason), we use the then method. The method receives two parameters, an onFulfilled function and an onRejected function. The rules to invoke these functions are the following:
- When the promise is in a
FULFILLEDstate, theonFulfilledfunction will be called with the promise’s fulfillmentvalue, e.g.,onFulfilled(value). - When the promise is in a
REJECTEDstate, theonRejectedfunction will be called with the promise’s rejectionreason, e.g.,onRejected(reason).
From now on, these functions will be referred to as promise handlers.
it('should have a .then method', () => {
const promise = new APromise(() => {})
expect(typeof promise.then).toBe('function')
})
it('should call the onFulfilled method when a promise is in a FULFILLED state', () => {
const value = ':)'
const onFulfilled = jest.fn()
const promise = new APromise((fulfill, reject) => {
fulfill(value)
})
.then(onFulfilled)
expect(onFulfilled.mock.calls.length).toBe(1)
expect(onFulfilled.mock.calls[0][0]).toBe(value)
})
it('transitions to the REJECTED state with a `reason`', () => {
const reason = 'I failed :('
const onRejected = jest.fn()
const promise = new APromise((fulfill, reject) => {
reject(reason)
})
.then(null, onRejected)
expect(onRejected.mock.calls.length).toBe(1)
expect(onRejected.mock.calls[0][0]).toBe(reason)
})
Let’s add the .then function to the class prototype. Note that it’ll call either the onFulfilled or onRejected function based on the state of the promise:
class APromise {
// ...
then(onFulfilled, onRejected) {
handleResolved(this, onFulfilled, onRejected)
}
// ...
}
function handleResolved(promise, onFulfilled, onRejected) {
const cb = promise.state === FULFILLED ? onFulfilled : onRejected
cb(promise.value)
}
One-way transition
Once the transition to either FULFILLED or REJECTED occurs, the promise must not transition to any other state.
const value = ':)'
const reason = 'I failed :('
it('when a promise is fulfilled it should not be rejected with another value', () => {
const onFulfilled = jest.fn()
const onRejected = jest.fn()
const promise = new APromise((resolve, reject) => {
resolve(value)
reject(reason)
})
promise.then(onFulfilled, onRejected)
expect(onFulfilled.mock.calls.length).toBe(1)
expect(onFulfilled.mock.calls[0][0]).toBe(value)
expect(onRejected.mock.calls.length).toBe(0)
expect(promise.state === 'FULFILLED')
})
it('when a promise is rejected it should not be fulfilled with another value', () => {
const onFulfilled = jest.fn()
const onRejected = jest.fn()
const promise = new APromise((resolve, reject) => {
reject(reason)
resolve(value)
})
promise.then(onFulfilled, onRejected)
expect(onRejected.mock.calls.length).toBe(1)
expect(onRejected.mock.calls[0][0]).toBe(reason)
expect(onFulfilled.mock.calls.length).toBe(0)
expect(promise.state === 'REJECTED')
})
In our current implementation, the function that calls the executor should make sure that either fulfill or reject is called only once. Subsequent calls should be ignored:
function doResolve(promise, executor) {
let called = false
function wrapFulfill(value) {
if (called) { return }
called = true
fulfill(promise, value)
}
function wrapReject(reason) {
if (called) { return }
called = true
reject(promise, reason)
}
executor(wrapFulfill, wrapReject)
}
Handling executor errors
If the execution of the executor fails, the promise should transition to the REJECTED state with the failure reason.
describe('handling executor errors', () => {
it('when the executor fails the promise should transition to the REJECTED state', () => {
const reason = new Error('I failed :(')
const onRejected = jest.fn()
const promise = new APromise((resolve, reject) => {
throw reason
})
promise.then(null, onRejected)
expect(onRejected.mock.calls.length).toBe(1)
expect(onRejected.mock.calls[0][0]).toBe(reason)
expect(promise.state === 'REJECTED')
})
})
The function that calls the executor should wrap it in a try/catch block and transition to REJECTED if the catch block is executed:
function doResolve(promise, executor) {
// ...
try {
executor(wrapFulfill, wrapReject)
} catch (err) {
wrapReject(err)
}
}
Async executor
If the resolver’s fulfill/reject are executed asynchronously, our .then method will fail because its handlers are executed immediately.
it('should queue callbacks when the promise is not fulfilled immediately', done => {
const value = ':)'
const promise = new APromise((fulfill, reject) => {
setTimeout(fulfill, 1, value)
})
const onFulfilled = jest.fn()
promise.then(onFulfilled)
setTimeout(() => {
// should have been called once
expect(onFulfilled.mock.calls.length).toBe(1)
expect(onFulfilled.mock.calls[0][0]).toBe(value)
promise.then(onFulfilled)
}, 5)
// should not be called immediately
expect(onFulfilled.mock.calls.length).toBe(0)
setTimeout(function () {
// should have been called twice
expect(onFulfilled.mock.calls.length).toBe(2)
expect(onFulfilled.mock.calls[1][0]).toBe(value)
done()
}, 10)
})
it('should queue callbacks when the promise is not rejected immediately', done => {
const reason = 'I failed :('
const promise = new APromise((fulfill, reject) => {
setTimeout(reject, 1, reason)
})
const onRejected = jest.fn()
promise.then(null, onRejected)
setTimeout(() => {
// should have been called once
expect(onRejected.mock.calls.length).toBe(1)
expect(onRejected.mock.calls[0][0]).toBe(reason)
promise.then(null, onRejected)
}, 5)
// should not be called immediately
expect(onRejected.mock.calls.length).toBe(0)
setTimeout(function () {
// should have been called twice
expect(onRejected.mock.calls.length).toBe(2)
expect(onRejected.mock.calls[1][0]).toBe(reason)
done()
}, 10)
})
Let’s add a queue to the promise. Its purpose is to store handlers that will be called once the promise state changes from PENDING to something else. At the same time, our .then method should check the promise state to decide whether to call the handler immediately or to store it. Let’s move this logic to a new helper function, handle:
class APromise {
constructor(executor) {
this.state = PENDING
// .then handler queue
this.queue = []
doResolve(this, executor)
}
then(onFulfilled, onRejected) {
handle(this, { onFulfilled, onRejected })
}
}
// checks the state of the promise to either:
// - queue it for later use if the promise is PENDING
// - call the handler if the promise is not PENDING
function handle(promise, handler) {
if (promise.state === PENDING) {
// queue if PENDING
promise.queue.push(handler)
} else {
// execute immediately
handleResolved(promise, handler)
}
}
function handleResolved(promise, handler) {
const cb = promise.state === FULFILLED ? handler.onFulfilled : handler.onRejected
cb(promise.value)
}
Also, the fulfill and reject methods should be updated so that they invoke all the handlers stored in the promise when called. This is implemented in a new function, finale, called after the state and the value have been updated.
function fulfill(promise, value) {
promise.state = FULFILLED
promise.value = value
finale(promise)
}
function reject(promise, reason) {
promise.state = REJECTED
promise.value = reason
finale(promise)
}
// invoke all the handlers stored in the promise
function finale(promise) {
const length = promise.queue.length
for (let i = 0; i < length; i += 1) {
handle(promise, promise.queue[i])
}
}
Chaining promises
Our .then methods should return a new promise. Note that in the example below, p.then returns a promise q. The handler qOnFulfilled is stored on q, and the handler rOnFulfilled is stored in r.
it('.then should return a new promise', () => {
expect(function() {
const qOnFulfilled = jest.fn()
const rOnFulfilled = jest.fn()
const p = new APromise(fulfill => fulfill())
const q = p.then(qOnFulfilled)
const r = q.then(rOnFulfilled)
}).not.toThrow()
})
The implementation is again straightforward. However, as we’ll see, the new promise transitions to a different state in a different way than by using an executor. The new promise uses the handlers to make the transition as follows:
- if the
onFulfilledoronRejectedfunction is called:- if there are no errors executing it, the promise will transition to the
FULFILLEDstate with the returned value as the fulfillmentvalue. - if there is an error executing it, the promise will transition to the
REJECTEDstate with the error as the rejectionreason.
- if there are no errors executing it, the promise will transition to the
Let’s make the .then method return a promise first:
class APromise {
// ...
then(onFulfilled, onRejected) {
// empty executor
const promise = new APromise(() => {})
handle(this, { onFulfilled, onRejected })
return promise
}
}
And then write the test to handle the new promise resolution:
it('if .then\'s onFulfilled is called without errors it should transition to FULFILLED', () => {
const value = ':)'
const f1 = jest.fn()
new APromise(fulfill => fulfill())
.then(() => value)
.then(f1)
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
})
it('if .then\'s onRejected is called without errors it should transition to FULFILLED', () => {
const value = ':)'
const f1 = jest.fn()
new APromise((fulfill, reject) => reject())
.then(null, () => value)
.then(f1)
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
})
it('if .then\'s onFulfilled is called and has an error it should transition to REJECTED', () => {
const reason = new Error('I failed :(')
const f1 = jest.fn()
new APromise(fulfill => fulfill())
.then(() => { throw reason })
.then(null, f1)
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(reason)
})
it('if .then\'s onRejected is called and has an error it should transition to REJECTED', () => {
const reason = new Error('I failed :(')
const f1 = jest.fn()
new APromise((fulfill, reject) => reject())
.then(null, () => { throw reason })
.then(null, f1)
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(reason)
})
For the implementation, we first have to store the new promise in the handler queue as well. That way, if the observed promise is resolved, the elements in the queue know which promise they need to resolve.
class APromise {
// ...
then(onFulfilled, onRejected) {
const promise = new APromise(() => {})
// store the promise as well
handle(this, { promise, onFulfilled, onRejected })
return promise
}
}
function handleResolved(promise, handler) {
const cb = promise.state === FULFILLED ? handler.onFulfilled : handler.onRejected
// execute the handler and transition according to the rules
try {
const value = cb(promise.value)
fulfill(handler.promise, value)
} catch (err) {
reject(handler.promise, err)
}
}
Async handlers
Next, let’s consider the case where a handler returns a promise. In this case, the promise that’s part of the handler (not the returned promise) should adopt the state and fulfillment value or rejection reason of the returned promise.
it('if a handler returns a promise, the previous promise should ' +
'adopt the state of the returned promise', () => {
const value = ':)'
const f1 = jest.fn()
new APromise(fulfill => fulfill())
.then(() => new APromise(resolve => resolve(value)))
.then(f1)
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
})
it('if a handler returns a promise resolved in the future, ' +
'the previous promise should adopt its value', done => {
const value = ':)'
const f1 = jest.fn()
new APromise(fulfill => setTimeout(fulfill, 0))
.then(() => new APromise(resolve => setTimeout(resolve, 0, value)))
.then(f1)
setTimeout(() => {
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
done()
}, 10)
})
Let’s imagine the following scenario:
const executor = fulfill => setTimeout(fulfill, 0, 'p')
const p = new APromise(executor)
const qOnFulfilled = value =>
new APromise(fulfill => fulfill(value + 'q'))
const q = p.then(qOnFulfilled)
const rOnFulfilled = value => (
// value should be 'pq'
)
const r = q.then(rOnFulfilled)
In our current implementation, the tuple { q, qOnFulfilled } is stored in the handlers of p, and we are sure that qOnFulfilled is called before storing the tuple { r, rOnFulfilled } in q. We could take advantage of this fact and detect when a handler returns a promise to store observers in the returned promise instead, e.g., store { r, onFulfilled } on the promise returned by qOnFulfilled.
Note that we’re using a while loop because a nested promise might itself have another promise as its resolution value.
function handle(promise, handler) {
// take the state of the innermost promise
while (promise.value instanceof APromise) {
promise = promise.value
}
if (promise.state === PENDING) {
// queue if PENDING
promise.queue.push(handler)
} else {
// execute immediately
handleResolved(promise, handler)
}
}
Additional cases
Invalid handlers
If the handler that was supposed to be a function is not a function, our implementation will fail.
it('works with invalid handlers (fulfill)', () => {
const value = ':)'
const f1 = jest.fn()
const p = new APromise(fulfill => fulfill(value))
const q = p.then(null)
q.then(f1)
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
})
it('works with invalid handlers (reject)', () => {
const reason = 'I failed :('
const r1 = jest.fn()
const p = new APromise((fulfill, reject) => reject(reason))
const q = p.then(null, null)
q.then(null, r1)
expect(r1.mock.calls.length).toBe(1)
expect(r1.mock.calls[0][0]).toBe(reason)
})
Let’s imagine the following scenario:
const p = new APromise(fulfill => fulfill('p'))
const qOnFulfilled = null
const q = p.then(qOnFulfilled)
In this case, q should be resolved right away with the resolution value of p.
function handleResolved(promise, handler) {
const cb = promise.state === FULFILLED ? handler.onFulfilled : handler.onRejected
// resolve immediately if the handler is not a function
if (typeof cb !== 'function') {
if (promise.state === FULFILLED) {
fulfill(handler.promise, promise.value)
} else {
reject(handler.promise, promise.value)
}
return
}
try {
const ret = cb(promise.value)
fulfill(handler.promise, ret)
} catch (err) {
reject(handler.promise, err)
}
}
Execute the handlers after the event loop
Requirement 2.2.4 : As pointed out in 3.1 , the handlers are called with a fresh stack. Also, this makes the promise resolution consistent by ensuring that the observers are called in the future, even if the executor/handlers are synchronous.
it('the promise observers are called after the event loop', done => {
const value = ':)'
const f1 = jest.fn()
let resolved = false
const p = new APromise(fulfill => {
fulfill(value) // should not execute f1 immediately
resolved = true
}).then(f1)
expect(f1.mock.calls.length).toBe(0)
setTimeout(function () {
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
expect(resolved).toBe(true)
done()
}, 10)
})
We can use any function that allows us to call a function after the event loop. This includes setTimeout, setImmediate, and requestAnimationFrame.
function handleResolved(promise, handler) {
setImmediate(() => {
// ...
})
}
NOTE: Most of the unit tests must be changed to be async as well.
Reject with a resolved promise as a reason
Requirement 2.2.7.2
it('rejects with a resolved promise', done => {
const value = ':)'
const reason = new APromise(fulfill => fulfill(value))
const r1 = jest.fn()
const p = new APromise(fulfill => fulfill())
.then(() => { throw reason })
.then(null, r1)
expect(r1.mock.calls.length).toBe(0)
setTimeout(function () {
expect(r1.mock.calls.length).toBe(1)
expect(r1.mock.calls[0][0]).toBe(reason)
done()
}, 10)
})
Only adopt the state of the nested promise if the promise is not in a REJECTED state.
function handle(promise, handler) {
// take the state of the returned promise
while (promise.state !== REJECTED && promise.value instanceof APromise) {
promise = promise.value
}
if (promise.state === PENDING) {
// queue if PENDING
promise.queue.push(handler)
} else {
// execute handler (after the event loop)
handleResolved(promise, handler)
}
}
A promise shouldn’t be resolved with itself
Requirement 2.3.1
it('should throw when attempted to be resolved with itself', done => {
const r1 = jest.fn()
const p = new APromise(fulfill => fulfill())
const q = p.then(() => q)
q.then(null, r1)
setTimeout(function () {
expect(r1.mock.calls.length).toBe(1)
expect(r1.mock.calls[0][0] instanceof TypeError).toBe(true)
done()
}, 10)
})
On the fulfill method, let’s check that the fulfillment value is not equal to the promise itself. If so, then throw a TypeError as mentioned in
2.3.1
.
function fulfill(promise, value) {
if (value === promise) {
return reject(promise, new TypeError())
}
promise.state = FULFILLED
promise.value = value
finale(promise)
}
Thenables
Related requirement
2.3.3.3
: The handler’s returned value may be a thenable, an object/function that has a then property that is accessible and is a function. The then function is like an executor; it receives fulfill and reject callbacks that should be used to transition the state of the thenable.
it('should work with thenables', done => {
const value = ':)'
const thenable = {
then: fulfill => fulfill(value)
}
const f1 = jest.fn()
new APromise(fulfill => fulfill(value))
.then(() => thenable)
.then(f1)
setTimeout(function () {
expect(f1.mock.calls.length).toBe(1)
expect(f1.mock.calls[0][0]).toBe(value)
done()
}, 10)
})
Let’s modify the fulfill method and add the check for thenables. Note that accessing a property is not always a safe operation (e.g., the property might be defined using a
getter
that fails), which is why we should wrap it in a try/catch.
Also, note that by requirement
2.3.3.3
, the thenable’s then should be called with the thenable as this.
function fulfill(promise, value) {
if (value === promise) {
return reject(promise, new TypeError())
}
if (value && (typeof value === 'object' || typeof value === 'function')) {
let then
try {
then = value.then
} catch (err) {
return reject(promise, err)
}
// promise
if (then === promise.then && promise instanceof APromise) {
promise.state = FULFILLED
promise.value = value
return finale(promise)
}
// thenable
if (typeof then === 'function') {
return doResolve(promise, then.bind(value))
}
}
// primitive
promise.state = FULFILLED
promise.value = value
finale(promise)
}
The end
That was it! What I learned from implementing it on my own was that a promise can be a rejection error. Previously, I thought that promises would never be something that an observer would receive. I thought that all promises were unwrapped before being sent to the observer.
This is the final version of our tests and the promise implementation:
Running the A+ Promise compliance tests
This implementation passed all the 872 tests, cool!
872 passing (14s)
Improvements
- Add a task queue so that the execution of multiple handlers happens in a batch (it’s not actually a batch; the way the event loop works is that multiple calls to an API like
setTimeoutwill add multiple tasks to the task queue as well. However, if we send them in a batch, all the handlers will be executed in a row in the next event loop). - Add missing methods:
Promise.all,Promise.race, and the like. - Performance improvements: The creator of Bluebird has a detailed document with some optimization tips.
- Async stack traces: See
q.