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 the some tests first and then implement what’s needed to make the tests pass (tests will be written on the platform Jest
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
, 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)
- fromPENDING
toFULFILLED
withvalue
, thevalue
is now a property of the promise.reject(reason)
- fromPENDING
toREJECTED
withreason
, thereason
is 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 2 parameters, an onFulfilled
function and an onRejected
function, the rules to invoke these functions are the following:
- when the promise is in a
FULFILLED
state theonFulfilled
function will be called with the promise’s fulfillmentvalue
e.g.onFulfilled(value)
- when the promise is in a
REJECTED
state theonRejected
function will be called with the promise’s rejectionreason
e.g.onRejected(reason)
From now on these functions will be referred 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 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 the handler, 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
, 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
, also 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 using a executor, the new promise uses the handlers to make the transition as follows:
- if the
onFulfilled
oronRejected
function is called- if there are no errors executing it, the promise will transition to the
FULFILLED
state with the returned value as the fulfillmentvalue
- if there is an error executing it, the promise will transition to the
REJECTED
state 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
because a nested promise might itself have another promise as the 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 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 that is a function, the then
function is like a executor, it receives a 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), that’s why we should wrap it in a try/catch.
Also, note that by the 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 the promises were unwrapped before sending them 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
setTimeout
will 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