On this page
Polyfill: Promisehigh-yield
Last reviewed 22 Sept 2026
Problem
Implement a MyPromise class that behaves like the native Promise for the common cases:
new MyPromise((resolve) => setTimeout(() => resolve(1), 100)) .then((v) => v + 1) .then((v) => { throw new Error('boom ' + v); }) .catch((e) => e.message) // "boom 2" .finally(() => console.log('done')) .then(console.log); // "boom 2"Clarifying questions
- Which parts are needed:
then,catch,finally,MyPromise.resolve/reject? Usually all of these.alland friends are a separate problem. - Must callbacks run asynchronously even when the promise is already settled? Yes — always as a microtask.
- Should resolving with another promise (or any thenable) adopt its state? Yes; this is the part most candidates miss.
- Do I need full Promises/A+ compliance? No — the core behaviour is enough.
Approach
A promise is a small state machine: pending → fulfilled or rejected, exactly once. While pending it stores callbacks. then always returns a new promise whose outcome depends on what the callback returns or throws — that is what makes chaining work. Callbacks are scheduled with queueMicrotask, so they run after the current synchronous code, like the native ones.
Step-by-step build
Step 1 — state and the executor
class MyPromise { #state = 'pending'; #value; #handlers = [];
constructor(executor) { const settle = (state, value) => { if (this.#state !== 'pending') return; // settle only once this.#state = state; this.#value = value; this.#handlers.forEach((h) => h()); this.#handlers = []; }; try { executor((v) => settle('fulfilled', v), (e) => settle('rejected', e)); } catch (e) { settle('rejected', e); // a throwing executor rejects } }}Step 2 — then returns a new promise
then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { const run = () => queueMicrotask(() => { const cb = this.#state === 'fulfilled' ? onFulfilled : onRejected; if (typeof cb !== 'function') { // no handler: pass the value or error through to the next promise return this.#state === 'fulfilled' ? resolve(this.#value) : reject(this.#value); } try { resolve(cb(this.#value)); } catch (e) { reject(e); } }); if (this.#state === 'pending') this.#handlers.push(run); else run(); });}Step 3 — adopt promises and thenables
If resolve receives something with a then method, the new promise must follow it instead of fulfilling with the object itself.
const resolve = (v) => { if (v === this) return reject(new TypeError('Chaining cycle detected')); if (v !== null && (typeof v === 'object' || typeof v === 'function')) { let then; try { then = v.then; } catch (e) { return reject(e); } if (typeof then === 'function') { let called = false; // a thenable may call back more than once try { then.call(v, (x) => { if (!called) { called = true; resolve(x); } }, (e) => { if (!called) { called = true; reject(e); } }); } catch (e) { if (!called) reject(e); } return; } } settle('fulfilled', v);};Step 4 — catch, finally and the statics
catch(onRejected) { return this.then(undefined, onRejected); }
finally(onFinally) { // runs the callback, then passes the ORIGINAL outcome through return this.then( (v) => MyPromise.resolve(onFinally()).then(() => v), (e) => MyPromise.resolve(onFinally()).then(() => { throw e; }), );}
static resolve(v) { return v instanceof MyPromise ? v : new MyPromise((res) => res(v)); }static reject(e) { return new MyPromise((_, rej) => rej(e)); }Final code
class MyPromise { #state = 'pending'; #value; #handlers = [];
constructor(executor) { const settle = (state, value) => { if (this.#state !== 'pending') return; this.#state = state; this.#value = value; const handlers = this.#handlers; this.#handlers = []; handlers.forEach((h) => h()); };
let done = false; // resolve/reject from the executor only count once const reject = (e) => { if (!done) { done = true; settle('rejected', e); } }; const resolve = (v) => { if (done) return; done = true; this.#adopt(v, (x) => settle('fulfilled', x), (e) => settle('rejected', e)); };
try { executor(resolve, reject); } catch (e) { reject(e); } }
// Follow promises and thenables; fulfil directly with anything else. #adopt(v, fulfil, reject) { if (v === this) return reject(new TypeError('Chaining cycle detected for promise')); if (v !== null && (typeof v === 'object' || typeof v === 'function')) { let then; try { then = v.then; } catch (e) { return reject(e); } if (typeof then === 'function') { let called = false; try { then.call( v, (x) => { if (!called) { called = true; this.#adopt(x, fulfil, reject); } }, (e) => { if (!called) { called = true; reject(e); } }, ); } catch (e) { if (!called) reject(e); } return; } } fulfil(v); }
then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { const run = () => queueMicrotask(() => { const fulfilled = this.#state === 'fulfilled'; const cb = fulfilled ? onFulfilled : onRejected; if (typeof cb !== 'function') return fulfilled ? resolve(this.#value) : reject(this.#value); try { resolve(cb(this.#value)); } catch (e) { reject(e); } }); if (this.#state === 'pending') this.#handlers.push(run); else run(); }); }
catch(onRejected) { return this.then(undefined, onRejected); }
finally(onFinally) { return this.then( (v) => MyPromise.resolve(onFinally?.()).then(() => v), (e) => MyPromise.resolve(onFinally?.()).then(() => { throw e; }), ); }
static resolve(v) { return v instanceof MyPromise ? v : new MyPromise((res) => res(v)); }
static reject(e) { return new MyPromise((_, rej) => rej(e)); }}Edge cases
- Resolving or rejecting twice: only the first call counts.
- The executor throws after calling
resolve: ignored, because the promise is already settled. thenwith no handlers (p.then()): the value passes through untouched.thenon an already-settled promise still runs the callback asynchronously.- Resolving a promise with itself rejects with a
TypeErrorinstead of hanging forever. finallydoes not change the value — unless its callback throws or returns a rejected promise, which replaces the outcome.
Follow-ups
- Order of logs:
console.log(1); MyPromise.resolve().then(() => console.log(2)); console.log(3);prints 1, 3, 2 — explain the microtask queue. - Why
queueMicrotaskand notsetTimeout?setTimeoutis a macrotask: callbacks would run after timers and I/O, unlike native promises. - Add
MyPromise.all/race. See Promise combinators. - Unhandled rejections: how would you report a rejection nobody handled? (Track whether any
then/catchwas attached before a microtask tick.)
Common mistakes
thenreturningthisinstead of a new promise — chaining then shares one state.- Calling callbacks synchronously when the promise is already settled.
- Not catching errors thrown inside callbacks, so they escape instead of rejecting the next promise.
- Fulfilling with a promise object instead of adopting its state.
Related
- Concepts: promises and the event loop in the JavaScript track.
- Next: Promise.all, allSettled, race and any.