Skip to content

Commit f5d70db

Browse files
committed
feat(promises): add withResolvers helper bound to native when available
Exposes the TC39 Promise.withResolvers API (https://tc39.es/ecma262/#sec-promise.withResolvers) as a first-class export so fleet code bases can retire the manual `let resolve; const p = new Promise(r => { resolve = r })` dance. - Bound to native `Promise.withResolvers` when `typeof === 'function'` (stable in Node 20.12+ / 21+ / 22+; V8 ≥ 12.0). Binds to `Promise` so destructured imports don't lose `this`. - Fallback implementation captures resolvers via closure and returns a spec-equivalent `{ promise, resolve, reject }` whose own data properties are enumerable on `Object.prototype` — matches §27.2.4.9 steps 3-6 (`OrdinaryObjectCreate` + `CreateDataPropertyOrThrow`). - Public `PromiseWithResolvers<T>` interface documents the return shape for external consumers. 12 new unit tests cover: function identity, return shape, resolve / reject roundtrip, thenable adoption (resolved + rejected), settle- once semantics, deferred resolution, independence across calls, and spec-shape assertions (`Object.prototype` prototype + enumerable own properties). Fallback verified by deleting the native method on the built dist and re-running: identical behavior on every assertion.
1 parent 7a15654 commit f5d70db

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

src/promises.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,3 +759,69 @@ export function resolveRetryOptions(
759759

760760
return options ? { ...defaults, ...options } : defaults
761761
}
762+
763+
/**
764+
* Shape returned by {@link withResolvers}: a fresh pending promise plus
765+
* the `resolve` / `reject` handles that settle it.
766+
*
767+
* Matches the spec return-shape exactly
768+
* ([ECMA-262 §27.2.4.9](https://tc39.es/ecma262/#sec-promise.withResolvers)).
769+
*/
770+
export interface PromiseWithResolvers<T> {
771+
/** The pending promise. */
772+
promise: Promise<T>
773+
/** Resolves {@link promise} with the given value (or thenable). */
774+
resolve: (value: T | PromiseLike<T>) => void
775+
/** Rejects {@link promise} with the given reason. */
776+
reject: (reason?: unknown) => void
777+
}
778+
779+
const maybeNativeWithResolvers = (
780+
Promise as unknown as {
781+
withResolvers?: unknown
782+
}
783+
).withResolvers
784+
785+
/**
786+
* Create a pending promise together with its `resolve` and `reject`
787+
* handles as first-class values, per
788+
* [ECMA-262 §27.2.4.9](https://tc39.es/ecma262/#sec-promise.withResolvers).
789+
*
790+
* Bound to native `Promise.withResolvers` when available (Node 20.12+ /
791+
* 21+ / 22+; V8 ≥ 12.0); otherwise falls back to a spec-equivalent
792+
* `new Promise(executor)` implementation that captures the handles via
793+
* closure. The returned object always has own data properties `promise`,
794+
* `resolve`, `reject` on `Object.prototype` — writable, enumerable, and
795+
* configurable — matching the spec's `CreateDataPropertyOrThrow` steps.
796+
*
797+
* Use this instead of the manual
798+
* `let resolve; const p = new Promise(r => { resolve = r })` dance for
799+
* deferred-resolution patterns (event-driven bridges, adapter layers,
800+
* handshake signaling) where the settle path lives outside the executor.
801+
*
802+
* @example
803+
* ```typescript
804+
* const { promise, resolve, reject } = withResolvers<string>()
805+
* emitter.once('ready', () => resolve('ok'))
806+
* emitter.once('error', err => reject(err))
807+
* const result = await promise
808+
* ```
809+
*/
810+
export const withResolvers: <T>() => PromiseWithResolvers<T> =
811+
typeof maybeNativeWithResolvers === 'function'
812+
? // Bind so callers who destructure the export don't lose `this`.
813+
((maybeNativeWithResolvers as () => PromiseWithResolvers<unknown>).bind(
814+
Promise,
815+
) as <T>() => PromiseWithResolvers<T>)
816+
: <T>(): PromiseWithResolvers<T> => {
817+
// Fallback: capture resolvers via closure. The `!` asserts hold
818+
// because Promise's executor runs synchronously, so both handles
819+
// are assigned before the constructor returns.
820+
let resolve!: (value: T | PromiseLike<T>) => void
821+
let reject!: (reason?: unknown) => void
822+
const promise = new Promise<T>((res, rej) => {
823+
resolve = res
824+
reject = rej
825+
})
826+
return { promise, resolve, reject }
827+
}

test/unit/promises.test.mts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
pFilterChunk,
2020
pRetry,
2121
resolveRetryOptions,
22+
withResolvers,
2223
} from '@socketsecurity/lib/promises'
2324
import { describe, expect, it, vi } from 'vitest'
2425

@@ -1048,4 +1049,98 @@ describe('promises', () => {
10481049
expect(options.retries).toBe(0)
10491050
})
10501051
})
1052+
1053+
describe('withResolvers', () => {
1054+
// Spec: https://tc39.es/ecma262/#sec-promise.withResolvers
1055+
// These tests exercise the feature-detect binding. On Node 20.12+ /
1056+
// 22+ the export is bound to native Promise.withResolvers; on older
1057+
// engines it's our fallback. Both paths must satisfy the spec.
1058+
1059+
it('is a function', () => {
1060+
expect(typeof withResolvers).toBe('function')
1061+
})
1062+
1063+
it('returns an object with promise, resolve, reject', () => {
1064+
const d = withResolvers<number>()
1065+
expect(d.promise).toBeInstanceOf(Promise)
1066+
expect(typeof d.resolve).toBe('function')
1067+
expect(typeof d.reject).toBe('function')
1068+
})
1069+
1070+
it('resolves the promise with the provided value', async () => {
1071+
const { promise, resolve } = withResolvers<string>()
1072+
resolve('hello')
1073+
await expect(promise).resolves.toBe('hello')
1074+
})
1075+
1076+
it('rejects the promise with the provided reason', async () => {
1077+
const { promise, reject } = withResolvers<number>()
1078+
const err = new Error('boom')
1079+
reject(err)
1080+
await expect(promise).rejects.toBe(err)
1081+
})
1082+
1083+
it('adopts a thenable passed to resolve', async () => {
1084+
const { promise, resolve } = withResolvers<number>()
1085+
resolve(Promise.resolve(42))
1086+
await expect(promise).resolves.toBe(42)
1087+
})
1088+
1089+
it('rejects when a rejected thenable is passed to resolve', async () => {
1090+
const { promise, resolve } = withResolvers<number>()
1091+
const err = new Error('inner')
1092+
resolve(Promise.reject(err))
1093+
await expect(promise).rejects.toBe(err)
1094+
})
1095+
1096+
it('settles exactly once — later resolve() calls are ignored', async () => {
1097+
const { promise, resolve } = withResolvers<string>()
1098+
resolve('first')
1099+
resolve('second')
1100+
await expect(promise).resolves.toBe('first')
1101+
})
1102+
1103+
it('settles exactly once — reject after resolve is ignored', async () => {
1104+
const { promise, resolve, reject } = withResolvers<string>()
1105+
resolve('ok')
1106+
reject(new Error('late'))
1107+
await expect(promise).resolves.toBe('ok')
1108+
})
1109+
1110+
it('supports deferred resolution from outside the executor', async () => {
1111+
// The point of withResolvers: settle from code that doesn't own the
1112+
// executor. Here an event-style callback closes over `resolve`.
1113+
const { promise, resolve } = withResolvers<string>()
1114+
setTimeout(() => resolve('fired'), 0)
1115+
await expect(promise).resolves.toBe('fired')
1116+
})
1117+
1118+
it('each call returns a fresh, independent capability', async () => {
1119+
const a = withResolvers<number>()
1120+
const b = withResolvers<number>()
1121+
expect(a.promise).not.toBe(b.promise)
1122+
expect(a.resolve).not.toBe(b.resolve)
1123+
a.resolve(1)
1124+
b.resolve(2)
1125+
await expect(a.promise).resolves.toBe(1)
1126+
await expect(b.promise).resolves.toBe(2)
1127+
})
1128+
1129+
// Spec §27.2.4.9 step 3: `OrdinaryObjectCreate(%Object.prototype%)`.
1130+
// The returned object is a plain object, not a Promise / subclass.
1131+
it('returned object has Object.prototype as its prototype', () => {
1132+
const d = withResolvers<number>()
1133+
expect(Object.getPrototypeOf(d)).toBe(Object.prototype)
1134+
})
1135+
1136+
// Spec §27.2.4.9 steps 4-6: properties created via
1137+
// `CreateDataPropertyOrThrow` — writable, enumerable, configurable.
1138+
it('promise/resolve/reject are own enumerable properties', () => {
1139+
const d = withResolvers<number>()
1140+
const keys = Object.keys(d)
1141+
expect(keys).toContain('promise')
1142+
expect(keys).toContain('resolve')
1143+
expect(keys).toContain('reject')
1144+
})
1145+
})
10511146
})

0 commit comments

Comments
 (0)