In my previous post, we created a promise implementation from scratch. We didn't however, make this implementation compliant with the A+ promise specification.
Here be dragons: This stuff is tricky & can be a little mind-bending. You've been warned!
Jump to the final code to see our final A+ promise implementation.
Let's run our implementation through a series of tests to see how close to compliance we are:
264 tests passed
608 tests failed
Umm, that's not great...
The promise A+ spec is incredibly specific on behaviour, this is intentional. Because there are so many implementations of promises, and their nature as a 'core' tool, it is important that promises behave very consistently across platforms. There is no undefined behaviour in the promise A+ spec. Let's modify out original implementation to cater for 'the weird bits' of promises and aim for 100% passing tests from the A+ spec.
Catching synchronous errors in our executor
Our first issue, is executor functions that throw while running.
new MyPromise((resolve, reject) => {
throw new Error("Uh-oh");
});
To fix that, we can simply wrap the call of our executor function in a try/catch
class MyPromise {
...
constructor(executor) {
// all promises start their life pending & with no fulfilled value
this.#state = "PENDING";
this.#fulfilledValue = undefined;
this.#handlers = [];
// immediately run our executor function
// provide it 2 functions (resolve, reject) to call when a result is ready
try {
executor(
(value) => this.#resolveWithValue(value),
(rejectReason) => this.#rejectWithReason(rejectReason)
);
} catch (e) {
// make sure we handle our executor function unexpectedly throwing
this.#rejectWithReason(e);
}
}
...
}
Resolving/rejecting multiple times
Points 2.1.2
and 2.1.3
in the specification require that we only allow a promise to resolve or reject once.
Adding checks to ensure resolving or rejecting when the promise is not in the 'pending' state:
class MyPromise {
...
#resolveWithValue(value) {
// a promise can only resolve or reject once
// if we aren't pending, we must have already resolved or rejected
if (this.#state !== "PENDING") {
return;
}
...
}
#rejectWithReason(rejectReason) {
// a promise can only resolve or reject once
// if we aren't pending, we must have already resolved or rejected
if (this.#state !== "PENDING") {
return;
}
...
}
...
}
Resolving with self
if resolve()
is called with a promise, we wait for that sub promise to resolve before resolving the outer promise.
We covered that in 'the core bits', however there is an edge case we missed here.
If we call resolve() with a reference to ourself, our promise will hang forever... because it is waiting for itself to resolve before it can resolve.
The spec solves this problem by simply rejecting if our executor tries to resolve with a self reference.
class MyPromise {
...
#resolveWithValue(value) {
// a promise can only resolve or reject once
// if we aren't pending, we must have already resolved or rejected
if (this.#state !== "PENDING") {
return;
}
if (value === this) {
this.#rejectWithReason(new TypeError('Cannot resolve promise with itself'));
}
...
}
...
}
Microtasks
I could explain microtasks, but I'd probably do a poor job. So I'll quote MDN instead
A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.
Essentially, we want to run any code we had lined up before we fire off our callbacks.
Updating our implementation to call every callback via queueMicrotask()
class MyPromise {
...
#resolveWithValue(value) {
...
// call every handler, to call .then handlers & resolve .then sub promises
for (const handler of this.#handlers) {
queueMicrotask(handler);
}
}
#rejectWithReason(rejectReason) {
...
// call every handler, to call .then handlers & resolve .then sub promises
for (const handler of this.#handlers) {
queueMicrotask(handler);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handleSettle = () => {
if (this.#state === "FULFILLED") {
if (onFulfilled) {
// we have a handler function for this!
// call it, then resolve with it's return value
// e.g.
// doThing() <-- doThing() just resolved
// .then(storeResult) <-- call storeResult when doThing state === "FULFILLED" (you are here)
// .then(notifyUser) <-- another .then handler is attached to the promise returned by .then(storeResult)
queueMicrotask(() => resolve(onFulfilled(this.#fulfilledValue)));
} else {
queueMicrotask(() => resolve(this.#fulfilledValue));
}
} else { // rejected
if (onRejected) {
queueMicrotask(() => resolve(onRejected(this.#fulfilledValue)));
} else {
queueMicrotask(() => reject(this.#fulfilledValue));
}
}
};
...
});
}
...
}
Not so bad! A lot of code but only a few lines in there we actually had to change.
Catching errors in then handlers
Similarly to how we need to handle errors from our executor function, we should also catch errors in our then handler functions. If a handler fails, we want to reject the promise with the error the handler threw. We want this same behaviour for both our onFulfilled and onRejected handlers, so lets abstract calling handlers, so we can handle errors consistently.
class MyPromise {
...
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
// wrapper function to safely call either our onFulfilled or onRejected function
// once handler function has returned, resolve our .then promise automatically
// catches all errors & automatically calls reject if anything fails
const callHandler = (handler) => {
// spec requires these to be called after the current task queue has completed
queueMicrotask(() => {
try {
resolve(handler(this.#fulfilledValue));
} catch (e) {
reject(e);
}
});
};
// we only have one function here for handling both fulfillment & rejection
// check what state the promise is in when this gets called, and call the appropriate handler function
// once the handler function has returned, resolve our .then promise (see callHandler above)
// if the handler function isn't provided or callable,
// just resolve our .then promise without calling any handler
const handleSettle = () => {
if (this.#state === "FULFILLED") {
if (onFulfilled) {
// we have a handler function for this!
// call it, then resolve with it's return value
// e.g.
// doThing() <-- doThing() just resolved
// .then(storeResult) <-- call storeResult when doThing state === "FULFILLED" (you are here)
// .then(notifyUser) <-- another .then handler is attached to the promise returned by .then(storeResult)
callHandler(onFulfilled);
} else {
resolve(this.#fulfilledValue);
}
} else { // rejected
if (onRejected) {
callHandler(onRejected);
} else {
reject(this.#fulfilledValue);
}
}
};
...
}
}
...
}
Thenables
We are currently checking if our executor is calling resolve()
with an instance of MyPromise.
However, the spec is actually a little more flexible when it comes to resolving with a sub promise.
A "thenable" is defined as
an object or function that defines a
then
method
We should be treating any 'thenable' object as a promise, rather than just instances of our own promise class. This means you could technically chain promises from different implementations... although for the sanity of your fellow developers please don't do that.
This contains many edge cases, what if .then is actually a getter on our object, and triggering that getter crashes?! Weirdly the spec also requires that the '.then' property is only fetched once. I'm not sure what the purpose of this is, but I guess it again removes any possible variance between implementations.
class MyPromise {
...
#resolveWithValue(value) {
...
try {
// make sure we only access the accessor once as required by the spec
const then = value && value.then;
// check if the value we have 'resolved' with is a promise
// if so, don't fulfill with this value, instead, wait for it to resolve
// then try again to resolve with the value of this sub promise
if (typeof value === "object" && typeof then === "function") {
// attach a 'then' handler to this promise that we were resolved with
// once it returns, call resolve/reject again with the value it fulfills to
then.call(value,
(result) => {
this.#resolveWithValue(result);
},
(rejectReason) => {
this.#rejectWithReason(rejectReason);
},
);
} else {
// update our local state & store the value we fulfilled with
this.#state = "FULFILLED";
this.#fulfilledValue = value;
// call every handler, to call .then handlers & resolve .then sub promises
for (const handler of this.#handlers) {
queueMicrotask(handler);
}
}
} catch (e) {
// catch if fetching .then or calling .then throws unexpectedly
this.#rejectWithReason(e);
}
}
...
}
thenables resolving/rejecting multiple times...
Argg, we've re-introduced a problem that we fixed earlier! If our sub promise resolves, then throws we will reject while we are in the process of resolving (possibly waiting for a sub promise to resolve). Let's add a flag to deal with that edge case:
class MyPromise {
...
#resolveWithValue(value) {
...
let hasCalledResolve = false;
try {
// make sure we only access the accessor once as required by the spec
const then = value && value.then;
// check if the value we have 'resolved' with is a promise
// if so, don't fulfill with this value, instead, wait for it to resolve
// then try again to resolve with the value of this sub promise
if (typeof value === "object" && typeof then === "function") {
// attach a 'then' handler to this promise that we were resolved with
// once it returns, call resolve/reject again with the value it fulfills to
then.call(value,
(result) => {
if (!hasCalledResolve) {
// ensure we can never resolve/reject multiple times
hasCalledResolve = true;
this.#resolveWithValue(result);
}
},
(rejectReason) => {
if (!hasCalledResolve) {
this.#rejectWithReason(rejectReason);
}
},
);
} else {
// update our local state & store the value we fulfilled with
this.#state = "FULFILLED";
this.#fulfilledValue = value;
// call every handler, to call .then handlers & resolve .then sub promises
for (const handler of this.#handlers) {
queueMicrotask(handler);
}
}
} catch (e) {
// catch if fetching .then or calling .then throws unexpectedly
if (!hasCalledResolve) {
this.#rejectWithReason(e);
}
}
}
...
}
Dealing with .then() being called with non function values
Almost there, finally the spec calls that we treat any non-function value provided as an onFulfilled/onRejected handler as undefined.
Updating our logic in .then()
to check if our param has a '.call' property.
class MyPromise {
...
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
...
const handleSettle = () => {
if (this.#state === "FULFILLED") {
if (onFulfilled && onFulfilled.call) {
...
}
} else { // rejected
if (onRejected && onRejected.call) {
...
} else {
...
}
}
};
...
});
}
...
Putting it all together
class MyPromise {
// use JavaScript private variables to hold state
#state; // "PENDING", "FULFILLED" or "REJECTED"
#fulfilledValue; // the value we FULFILL or REJECT with
#handlers; // array of functions to call when we FULFILL or REJECT
constructor(executor) {
// all promises start their life pending & with no fulfilled value
this.#state = "PENDING";
this.#fulfilledValue = undefined;
this.#handlers = [];
// immediately run our executor function
// provide it 2 functions (resolve, reject) to call when a result is ready
try {
executor(
(value) => this.#resolveWithValue(value),
(rejectReason) => this.#rejectWithReason(rejectReason)
);
} catch (e) {
// make sure we handle our executor function unexpectedly throwing
this.#rejectWithReason(e);
}
}
#resolveWithValue(value) {
// a promise can only resolve or reject once
// if we aren't pending, we must have already resolved or rejected
if (this.#state !== "PENDING") {
return;
}
if (value === this) {
this.#rejectWithReason(new TypeError('Cannot resolve promise with itself'));
}
let hasCalledResolve = false;
try {
// make sure we only access the accessor once as required by the spec
const then = value && value.then;
// check if the value we have 'resolved' with is a promise
// if so, don't fulfill with this value, instead, wait for it to resolve
// then try again to resolve with the value of this sub promise
if (typeof value === "object" && typeof then === "function") {
// attach a 'then' handler to this promise that we were resolved with
// once it returns, call resolve/reject again with the value it fulfills to
then.call(value,
(result) => {
if (!hasCalledResolve) {
// ensure we can never resolve/reject multiple times
hasCalledResolve = true;
this.#resolveWithValue(result);
}
},
(rejectReason) => {
if (!hasCalledResolve) {
this.#rejectWithReason(rejectReason);
}
},
);
} else {
// update our local state & store the value we fulfilled with
this.#state = "FULFILLED";
this.#fulfilledValue = value;
// call every handler, to call .then handlers & resolve .then sub promises
for (const handler of this.#handlers) {
queueMicrotask(handler);
}
}
} catch (e) {
// catch if fetching .then or calling .then throws unexpectedly
if (!hasCalledResolve) {
this.#rejectWithReason(e);
}
}
}
#rejectWithReason(rejectReason) {
// a promise can only resolve or reject once
// if we aren't pending, we must have already resolved or rejected
if (this.#state !== "PENDING") {
return;
}
// update our local state & store the value we rejected with
this.#state = "REJECTED";
this.#fulfilledValue = rejectReason;
// call every handler, to call .then handlers & resolve .then sub promises
for (const handler of this.#handlers) {
queueMicrotask(handler);
}
}
then(onFulfilled, onRejected) {
// calling .then always returns a new promise
// this allows us to chain promises together
// e.g.
// fetchData()
// .then(parseData)
// .then(storeData)
// .then(notifyUser)
return new MyPromise((resolve, reject) => {
// wrapper function to safely call either our onFulfilled or onRejected function
// once handler function has returned, resolve our .then promise automatically
// catches all errors & automatically calls reject if anything fails
const callHandler = (handler) => {
// spec requires these to be called after the current task queue has completed
queueMicrotask(() => {
try {
resolve(handler(this.#fulfilledValue));
} catch (e) {
reject(e);
}
});
};
// we only have one function here for handling both fulfillment & rejection
// check what state the promise is in when this gets called, and call the appropriate handler function
// once the handler function has returned, resolve our .then promise (see callHandler above)
// if the handler function isn't provided or callable,
// just resolve our .then promise without calling any handler
const handleSettle = () => {
if (this.#state === "FULFILLED") {
if (onFulfilled && onFulfilled.call) {
// we have a handler function for this!
// call it, then resolve with it's return value
// e.g.
// doThing() <-- doThing() just resolved
// .then(storeResult) <-- call storeResult when doThing state === "FULFILLED" (you are here)
// .then(notifyUser) <-- another .then handler is attached to the promise returned by .then(storeResult)
callHandler(onFulfilled);
} else {
resolve(this.#fulfilledValue);
}
} else { // rejected
if (onRejected && onRejected.call) {
callHandler(onRejected);
} else {
reject(this.#fulfilledValue);
}
}
};
// check to see if we have already resolved, or are still pending
// if still pending, store the handler function on our local #handlers array to be called later
// if already settled, immediately call handleSettle()
if (this.#state === "PENDING") {
// not settled yet, store handler so we can call once resolved
this.#handlers.push(handleSettle);
} else {
// already settled
handleSettle();
}
});
}
// convenience method, attach reject handler
catch(onRejected) {
this.then(undefined, onRejected);
}
// convenience method, handler to run either resolve or reject
finally(onFinally) {
this.then(onFinally, onFinally);
}
}
We got there, if you made it this far, well done!
Let's re-run our test suite and see how we go:
872 tests passed
0 tests failed