How it works
This version adds the resolution procedure and keeps each rule next to the Promises/A+ clause it implements.
class MyPromise {
#state = 'pending';
#value;
#reactions = [];
constructor(executor) {
const { resolve, reject } = this.#resolvingFunctions();
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
// Each pair is one-shot: the first call to either function wins.
#resolvingFunctions() {
let called = false;
const once = (fn) => (arg) => {
if (called) return;
called = true;
fn(arg);
};
return {
resolve: once((resolution) => this.#resolve(resolution)),
reject: once((reason) => this.#settle('rejected', reason)),
};
}
// Promises/A+ 2.3: the Promise Resolution Procedure.
#resolve(resolution) {
if (resolution === this) {
return this.#settle('rejected', new TypeError('A promise cannot resolve to itself'));
}
const isObjectLike =
typeof resolution === 'function' || (typeof resolution === 'object' && resolution !== null);
if (!isObjectLike) return this.#settle('fulfilled', resolution);
let then;
try {
then = resolution.then; // read once
} catch (error) {
return this.#settle('rejected', error);
}
if (typeof then !== 'function') return this.#settle('fulfilled', resolution);
const { resolve, reject } = this.#resolvingFunctions();
try {
then.call(resolution, resolve, reject);
} catch (error) {
reject(error); // ignored if resolve or reject already ran
}
}
#settle(state, value) {
this.#state = state;
this.#value = value;
for (const reaction of this.#reactions.splice(0)) queueMicrotask(reaction);
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const react = () => {
const fulfilled = this.#state === 'fulfilled';
const handler = fulfilled ? onFulfilled : onRejected;
if (typeof handler !== 'function') {
return fulfilled ? resolve(this.#value) : reject(this.#value);
}
let result;
try {
result = handler(this.#value);
} catch (error) {
return reject(error);
}
resolve(result);
};
if (this.#state === 'pending') this.#reactions.push(react);
else queueMicrotask(react);
});
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
}
How the pieces map to Promises/A+ 1.1:
- 2.1 States.
#state and #value change only in #settle. #settle is reachable only through a one-shot pair, so a promise settles once.
- 2.2
then. Both arguments are optional (2.2.1); handlers are called once with the value (2.2.2, 2.2.3); they run through queueMicrotask (2.2.4); they are plain calls inside a class, which is strict, so this is undefined (2.2.5); then can be called many times and reactions run in registration order (2.2.6); then returns a new promise that is resolved from the handler result, rejected on a throw, or given the original outcome when the handler is not a function (2.2.7).
- 2.3 Resolution procedure. Self-resolution is a
TypeError (2.3.1). A promise is handled as an ordinary thenable, which 2.3.2 and 2.3.3 both allow; then is read once and called with resolution as this and one-shot callbacks, and when y arrives it re-enters the procedure (2.3.3.3.1), which handles nested thenables. A throw from reading or calling then rejects unless a callback already ran (2.3.3.2, 2.3.3.3.4). A non-thenable is fulfilled (2.3.3.4, 2.3.4).
The one-shot pair is created again each time a thenable is adopted. The outer pair is already used up at that point, so the thenable's callbacks get their own guard, and calling both of them does nothing the second time.
Edge cases
Running the official suite, promises-aplus-tests 2.1.2 (installed with npx in a scratch directory), against MyPromise through the usual adapter (resolved, rejected and deferred built on the constructor) gave 872 passing, 0 failing. For contrast, the short MiniPromise fails most of the suite in the same setup (266 passing, 592 failing), because it has no resolution procedure. The one A+ note MyPromise does not follow is optional: 3.6 encourages detecting a circular chain of distinct thenables, and this code recurses until the stack overflows instead.
Timing differs from the native promise when a handler returns a promise. The native resolve schedules a separate job to call then on the returned promise, which costs extra ticks. MyPromise calls then immediately, which A+ allows. The result is observable:
for (const [name, P] of [['native', Promise], ['MyPromise', MyPromise]]) {
const log = [];
new P((r) => r()).then(() => { log.push('a1'); return new P((r) => r('x')); }).then(() => log.push('a2'));
new P((r) => r()).then(() => log.push('b1')).then(() => log.push('b2'))
.then(() => log.push('b3')).then(() => log.push('b4')).then(() => console.log(name, log.join(' ')));
}
// → native a1 b1 b2 b3 a2 b4
// → MyPromise a1 b1 b2 a2 b3 b4
(This uses the MyPromise class from above.) The A+ suite checks behaviour that the spec of both implementations shares, so passing it says nothing about matching native tick counts. Use native promises when tick-for-tick ordering matters.
Trade-offs
Both versions leave out finally, the static methods (resolve, reject, all) and unhandled-rejection reporting, because the interview version is about the state machine and the resolution procedure. For production, native promises are faster, report unhandled rejections to the host, and integrate with async/await; await on a custom thenable still works because await reads then the same way.