Implement your own version of Promise.withResolvers — the ES2024 helper that hands you a fresh promise alongside its resolve and reject functions, so you can settle the promise from outside its executor. Before this method existed, you had to declare two let bindings in the outer scope, then assign them inside new Promise(...) — workable, but noisy enough that it became a near-universal helper in every promise-heavy codebase. The new static method does that boilerplate once, correctly, and returns the three things you need as a plain object.
Your job is to implement a function named promiseWithResolvers that returns { promise, resolve, reject }. The resolve and reject functions must be the same ones the promise's executor would have received, so calling them from anywhere settles the promise just as if you'd called them inside the executor.
// Returns a triple: a new pending Promise, plus the resolve and reject
// functions that settle it from outside.
function promiseWithResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
// Basic resolve from outside the executor.
const { promise, resolve } = promiseWithResolvers();
setTimeout(() => resolve(42), 10);
promise.then(console.log); // logs 42 after ~10ms
// Basic reject.
const { promise: p2, reject } = promiseWithResolvers();
reject(new Error('nope'));
p2.catch((e) => console.log(e.message)); // logs "nope"
// Event-handler bridge: turn a one-shot DOM event into an awaitable.
function nextClick(button) {
const { promise, resolve } = promiseWithResolvers();
button.addEventListener('click', resolve, { once: true });
return promise;
}
// Caller can now: const event = await nextClick(myButton);
{ promise, resolve, reject }. Test code destructures these keys by name.resolve and reject you return must be the actual functions the executor was called with — not wrappers around them. Calling resolve(value) from outside must fulfil promise with value.resolve (or a reject after resolve) is silently ignored. You don't need to enforce this yourself — the native Promise constructor already does.Promise constructor. You are building a small adapter on top of new Promise(...). You are not writing a promise implementation from scratch.Promise.withResolvers.call(MyPromise)), prototype pollution, or non-Promise return types. Always return a plain object with a real Promise instance.You'll write a tiny wrapper around new Promise(...) that lifts the executor's resolve and reject out into the caller's scope, then hands all three back as a plain object.
Every now and then you need a promise whose resolve and reject you can call from somewhere other than inside the executor function — from a click handler attached later, from a setTimeout callback, from another module entirely. Before Promise.withResolvers landed in ES2024, every codebase invented the same six-line helper to do this. The new method ships that helper in the language: call it, destructure { promise, resolve, reject }, settle the promise from wherever you like.
The whole trick is that the Promise constructor's executor runs synchronously, before new Promise(...) returns. That means if you stash references to res and rej into outer-scope variables during the executor, those variables are populated by the time the constructor finishes. There's no race, no await, no "what if the executor hasn't run yet" — the spec guarantees it has.
The pre-ES2024 idiom — and the version most senior developers reach for from muscle memory — looks like this:
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// ... now `resolve` and `reject` are usable down here.
This works. It's not buggy. But three things make it awkward enough that the committee added a built-in:
let resolve, reject — two mutable bindings declared without initial values. Linters complain. TypeScript needs a definite-assignment assertion or a union with undefined.res/rej inside the executor are easy to typo, and the assignment direction (resolve = res, not res = resolve) trips people up.The naive implementation of promiseWithResolvers is just this idiom wrapped in a function. That's actually exactly what we're going to ship. The "failure" we're fixing isn't a correctness bug — it's a usability bug at the call site. Our helper hides the awkwardness so the caller only writes a single destructure.
function promiseWithResolvers() {
// Two outer-scope bindings. `let` is required because they're assigned
// inside the executor below — `const` would forbid that reassignment.
// TypeScript users would type these as `(value: T) => void` and similar.
let resolve, reject;
// The executor runs synchronously before `new Promise` returns. That's
// the spec-mandated invariant we rely on: by the time the next line
// executes, `resolve` and `reject` are bound to real functions.
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// Return a plain object. The shape `{ promise, resolve, reject }` is
// the documented contract — callers destructure these keys by name.
return { promise, resolve, reject };
}
module.exports = { promiseWithResolvers };
Three things are doing the work. First, let for the two bindings — const would block the assignment inside the executor. Second, the assumption that the executor runs synchronously — without that, we'd have to await something before returning, and the return value would be wrong. Third, returning a plain object literal so the caller can destructure all three names in one statement.
Trace this concrete usage:
const { promise, resolve, reject } = promiseWithResolvers();
setTimeout(() => resolve(42), 10);
const value = await promise;
console.log(value);
Step by step:
promiseWithResolvers() is called. Inside the function, let resolve, reject creates two outer-scope bindings, both undefined.new Promise((res, rej) => { ... }) runs. The Promise constructor immediately calls the executor with two fresh functions: res (a bound [[Resolve]]) and rej (a bound [[Reject]]). All of this happens synchronously, in the same tick.resolve = res and reject = rej. The outer-scope bindings now point at the real settle functions. The executor returns.promise is now a pending Promise instance.return { promise, resolve, reject } packages all three references into an object. The caller destructures them.setTimeout(() => resolve(42), 10) schedules a callback for ~10ms later. We continue past it immediately.await promise suspends the current async function. The job queue is now empty until the timer fires.resolve(42). Because resolve is the real [[Resolve]] bound to promise, this fulfils promise with 42.await resumes with the value 42.console.log(value) prints 42.The critical bit is step 3: by the time the constructor returns, resolve and reject are bound. If the executor were async, step 5 would return undefined for both — and the helper would be useless. The spec invariant makes the pattern work.
await, queueMicrotask, setTimeout). If you did, you'd hit a brief window where resolve/reject are still undefined. Keep the executor body to the two assignments.resolve twice is a no-op. Once a promise is settled (fulfilled or rejected), subsequent calls to either function are silently ignored. The native constructor enforces this — you don't need a settled flag. But it means resolve(a); resolve(b) resolves with a, not b.resolve/reject indefinitely is a memory leak. As long as your code keeps references to either function, the promise can't be garbage-collected, and neither can anything captured in its .then chain. If you create a promise this way and the settle never happens — say, the user never clicks the button — the promise leaks. Pair every withResolvers() with a timeout, an AbortSignal, or a clear lifecycle rule.resolve(promise) adopts state, including rejection. If you call resolve(somePromise) and somePromise rejects, your promise also rejects — resolve doesn't mean "fulfil no matter what." This is the same adoption rule as the native Promise.resolve(...).resolve to addEventListener('click', resolve, { once: true }) and await promise elsewhere. One click, one resolution, no manual unsubscribe — { once: true } cleans up the listener.{ promise, resolve, reject } triples. Workers push their pending promises onto the array; a separate draining loop pops triples and calls resolve(result) as results arrive. The pattern shows up in batch loaders, websocket request/response pairs, and async iterator implementations.cancel() method backed by the reject half of a withResolvers pair. Long-running operations race their own work against token.promise — if token.cancel() fires first, the operation aborts. The shape is small enough to inline into any feature that needs cooperative cancellation.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement your own version of Promise.withResolvers — the ES2024 helper that hands you a fresh promise alongside its resolve and reject functions, so you can settle the promise from outside its executor. Before this method existed, you had to declare two let bindings in the outer scope, then assign them inside new Promise(...) — workable, but noisy enough that it became a near-universal helper in every promise-heavy codebase. The new static method does that boilerplate once, correctly, and returns the three things you need as a plain object.
Your job is to implement a function named promiseWithResolvers that returns { promise, resolve, reject }. The resolve and reject functions must be the same ones the promise's executor would have received, so calling them from anywhere settles the promise just as if you'd called them inside the executor.
// Returns a triple: a new pending Promise, plus the resolve and reject
// functions that settle it from outside.
function promiseWithResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
// Basic resolve from outside the executor.
const { promise, resolve } = promiseWithResolvers();
setTimeout(() => resolve(42), 10);
promise.then(console.log); // logs 42 after ~10ms
// Basic reject.
const { promise: p2, reject } = promiseWithResolvers();
reject(new Error('nope'));
p2.catch((e) => console.log(e.message)); // logs "nope"
// Event-handler bridge: turn a one-shot DOM event into an awaitable.
function nextClick(button) {
const { promise, resolve } = promiseWithResolvers();
button.addEventListener('click', resolve, { once: true });
return promise;
}
// Caller can now: const event = await nextClick(myButton);
{ promise, resolve, reject }. Test code destructures these keys by name.resolve and reject you return must be the actual functions the executor was called with — not wrappers around them. Calling resolve(value) from outside must fulfil promise with value.resolve (or a reject after resolve) is silently ignored. You don't need to enforce this yourself — the native Promise constructor already does.Promise constructor. You are building a small adapter on top of new Promise(...). You are not writing a promise implementation from scratch.Promise.withResolvers.call(MyPromise)), prototype pollution, or non-Promise return types. Always return a plain object with a real Promise instance.You'll write a tiny wrapper around new Promise(...) that lifts the executor's resolve and reject out into the caller's scope, then hands all three back as a plain object.
Every now and then you need a promise whose resolve and reject you can call from somewhere other than inside the executor function — from a click handler attached later, from a setTimeout callback, from another module entirely. Before Promise.withResolvers landed in ES2024, every codebase invented the same six-line helper to do this. The new method ships that helper in the language: call it, destructure { promise, resolve, reject }, settle the promise from wherever you like.
The whole trick is that the Promise constructor's executor runs synchronously, before new Promise(...) returns. That means if you stash references to res and rej into outer-scope variables during the executor, those variables are populated by the time the constructor finishes. There's no race, no await, no "what if the executor hasn't run yet" — the spec guarantees it has.
The pre-ES2024 idiom — and the version most senior developers reach for from muscle memory — looks like this:
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// ... now `resolve` and `reject` are usable down here.
This works. It's not buggy. But three things make it awkward enough that the committee added a built-in:
let resolve, reject — two mutable bindings declared without initial values. Linters complain. TypeScript needs a definite-assignment assertion or a union with undefined.res/rej inside the executor are easy to typo, and the assignment direction (resolve = res, not res = resolve) trips people up.The naive implementation of promiseWithResolvers is just this idiom wrapped in a function. That's actually exactly what we're going to ship. The "failure" we're fixing isn't a correctness bug — it's a usability bug at the call site. Our helper hides the awkwardness so the caller only writes a single destructure.
function promiseWithResolvers() {
// Two outer-scope bindings. `let` is required because they're assigned
// inside the executor below — `const` would forbid that reassignment.
// TypeScript users would type these as `(value: T) => void` and similar.
let resolve, reject;
// The executor runs synchronously before `new Promise` returns. That's
// the spec-mandated invariant we rely on: by the time the next line
// executes, `resolve` and `reject` are bound to real functions.
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// Return a plain object. The shape `{ promise, resolve, reject }` is
// the documented contract — callers destructure these keys by name.
return { promise, resolve, reject };
}
module.exports = { promiseWithResolvers };
Three things are doing the work. First, let for the two bindings — const would block the assignment inside the executor. Second, the assumption that the executor runs synchronously — without that, we'd have to await something before returning, and the return value would be wrong. Third, returning a plain object literal so the caller can destructure all three names in one statement.
Trace this concrete usage:
const { promise, resolve, reject } = promiseWithResolvers();
setTimeout(() => resolve(42), 10);
const value = await promise;
console.log(value);
Step by step:
promiseWithResolvers() is called. Inside the function, let resolve, reject creates two outer-scope bindings, both undefined.new Promise((res, rej) => { ... }) runs. The Promise constructor immediately calls the executor with two fresh functions: res (a bound [[Resolve]]) and rej (a bound [[Reject]]). All of this happens synchronously, in the same tick.resolve = res and reject = rej. The outer-scope bindings now point at the real settle functions. The executor returns.promise is now a pending Promise instance.return { promise, resolve, reject } packages all three references into an object. The caller destructures them.setTimeout(() => resolve(42), 10) schedules a callback for ~10ms later. We continue past it immediately.await promise suspends the current async function. The job queue is now empty until the timer fires.resolve(42). Because resolve is the real [[Resolve]] bound to promise, this fulfils promise with 42.await resumes with the value 42.console.log(value) prints 42.The critical bit is step 3: by the time the constructor returns, resolve and reject are bound. If the executor were async, step 5 would return undefined for both — and the helper would be useless. The spec invariant makes the pattern work.
await, queueMicrotask, setTimeout). If you did, you'd hit a brief window where resolve/reject are still undefined. Keep the executor body to the two assignments.resolve twice is a no-op. Once a promise is settled (fulfilled or rejected), subsequent calls to either function are silently ignored. The native constructor enforces this — you don't need a settled flag. But it means resolve(a); resolve(b) resolves with a, not b.resolve/reject indefinitely is a memory leak. As long as your code keeps references to either function, the promise can't be garbage-collected, and neither can anything captured in its .then chain. If you create a promise this way and the settle never happens — say, the user never clicks the button — the promise leaks. Pair every withResolvers() with a timeout, an AbortSignal, or a clear lifecycle rule.resolve(promise) adopts state, including rejection. If you call resolve(somePromise) and somePromise rejects, your promise also rejects — resolve doesn't mean "fulfil no matter what." This is the same adoption rule as the native Promise.resolve(...).resolve to addEventListener('click', resolve, { once: true }) and await promise elsewhere. One click, one resolution, no manual unsubscribe — { once: true } cleans up the listener.{ promise, resolve, reject } triples. Workers push their pending promises onto the array; a separate draining loop pops triples and calls resolve(result) as results arrive. The pattern shows up in batch loaders, websocket request/response pairs, and async iterator implementations.cancel() method backed by the reject half of a withResolvers pair. Long-running operations race their own work against token.promise — if token.cancel() fires first, the operation aborts. The shape is small enough to inline into any feature that needs cooperative cancellation.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.