diff --git a/async/deno.json b/async/deno.json index bcceeac9a110..0f53c3dc1802 100644 --- a/async/deno.json +++ b/async/deno.json @@ -16,6 +16,7 @@ "./unstable-throttle": "./unstable_throttle.ts", "./unstable-wait-for": "./unstable_wait_for.ts", "./unstable-semaphore": "./unstable_semaphore.ts", - "./unstable-circuit-breaker": "./unstable_circuit_breaker.ts" + "./unstable-circuit-breaker": "./unstable_circuit_breaker.ts", + "./unstable-all-keyed": "./unstable_all_keyed.ts" } } diff --git a/async/unstable_all_keyed.ts b/async/unstable_all_keyed.ts new file mode 100644 index 000000000000..b05423423c9c --- /dev/null +++ b/async/unstable_all_keyed.ts @@ -0,0 +1,184 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +// TC39 spec uses [[OwnPropertyKeys]] (Reflect.ownKeys) then filters for enumerable. +// This equivalent is faster on V8: Object.keys returns only enumerable string keys, +// so we only need to filter symbol keys for enumerability. See also assert/equal.ts. +function getEnumerableKeys(obj: object): PropertyKey[] { + const stringKeys = Object.keys(obj); + const symbolKeys = Object.getOwnPropertySymbols(obj).filter((key) => + Object.prototype.propertyIsEnumerable.call(obj, key) + ); + return [...stringKeys, ...symbolKeys]; +} + +/** + * A record type where values can be promise-like (thenables) or plain values. + * + * @typeParam T The base record type with resolved value types. + */ +export type PromiseRecord> = { + [K in keyof T]: PromiseLike | T[K]; +}; + +/** + * A record type where values are {@linkcode PromiseSettledResult} objects. + * + * @typeParam T The base record type with resolved value types. + */ +export type SettledRecord> = { + [K in keyof T]: PromiseSettledResult; +}; + +/** + * Resolves all values in a record of promises in parallel, returning a promise + * that resolves to a record with the same keys and resolved values. + * + * This is similar to {@linkcode Promise.all}, but for records instead of arrays, + * allowing you to use named keys instead of positional indices. + * + * If any promise rejects, the returned promise immediately rejects with the + * first rejection reason. The result object has a null prototype, matching the + * TC39 specification. + * + * This function implements the behavior proposed in the TC39 + * {@link https://github.com/tc39/proposal-await-dictionary | Await Dictionary} + * proposal (`Promise.allKeyed`). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam T The record shape with resolved (unwrapped) value types. For + * example, if passing `{ foo: Promise }`, `T` would be `{ foo: number }`. + * @param record A record where values are promise-like (thenables) or plain values. + * @returns A promise that resolves to a record with the same keys and resolved + * values. The result has a null prototype. + * @throws Rejects with the first rejection reason if any promise in the record + * rejects. + * + * @example Basic usage + * ```ts + * import { allKeyed } from "@std/async/unstable-all-keyed"; + * import { assertEquals } from "@std/assert"; + * + * const result = await allKeyed({ + * foo: Promise.resolve(1), + * bar: Promise.resolve("hello"), + * }); + * + * assertEquals(result, { foo: 1, bar: "hello" }); + * ``` + * + * @example Parallel HTTP requests + * ```ts no-assert ignore + * import { allKeyed } from "@std/async/unstable-all-keyed"; + * + * const { user, posts } = await allKeyed({ + * user: fetch("/api/user").then((r) => r.json()), + * posts: fetch("/api/posts").then((r) => r.json()), + * }); + * ``` + * + * @example Mixed promises and plain values + * ```ts + * import { allKeyed } from "@std/async/unstable-all-keyed"; + * import { assertEquals } from "@std/assert"; + * + * const result = await allKeyed({ + * promised: Promise.resolve(42), + * plain: "static", + * }); + * + * assertEquals(result, { promised: 42, plain: "static" }); + * ``` + */ +export function allKeyed>( + record: PromiseRecord, +): Promise { + const keys = getEnumerableKeys(record); + const values = keys.map((key) => record[key as keyof typeof record]); + + return Promise.all(values).then((resolved) => { + const result = Object.create(null) as T; + for (let i = 0; i < keys.length; i++) { + result[keys[i] as keyof T] = resolved[i] as T[keyof T]; + } + return result; + }); +} + +/** + * Resolves all values in a record of promises in parallel, returning a promise + * that resolves to a record with the same keys and {@linkcode PromiseSettledResult} + * objects as values. + * + * This is similar to {@linkcode Promise.allSettled}, but for records instead of + * arrays, allowing you to use named keys instead of positional indices. + * + * Unlike {@linkcode allKeyed}, this function never rejects due to promise + * rejections. Instead, each value in the result record is a + * {@linkcode PromiseSettledResult} object indicating whether the corresponding + * promise was fulfilled or rejected. The result object has a null prototype, + * matching the TC39 specification. + * + * This function implements the behavior proposed in the TC39 + * {@link https://github.com/tc39/proposal-await-dictionary | Await Dictionary} + * proposal (`Promise.allSettledKeyed`). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam T The record shape with resolved (unwrapped) value types. For + * example, if passing `{ foo: Promise }`, `T` would be `{ foo: number }`. + * @param record A record where values are promise-like (thenables) or plain values. + * @returns A promise that resolves to a record with the same keys and + * {@linkcode PromiseSettledResult} values. The result has a null prototype. + * + * @example Basic usage + * ```ts + * import { allSettledKeyed } from "@std/async/unstable-all-keyed"; + * import { assertEquals } from "@std/assert"; + * + * const settled = await allSettledKeyed({ + * success: Promise.resolve(1), + * failure: Promise.reject(new Error("oops")), + * }); + * + * assertEquals(settled.success, { status: "fulfilled", value: 1 }); + * assertEquals(settled.failure.status, "rejected"); + * ``` + * + * @example Error handling + * ```ts + * import { allSettledKeyed } from "@std/async/unstable-all-keyed"; + * import { assertEquals, assertExists } from "@std/assert"; + * + * const settled = await allSettledKeyed({ + * a: Promise.resolve("ok"), + * b: Promise.reject(new Error("fail")), + * c: Promise.resolve("also ok"), + * }); + * + * // Check individual results + * if (settled.a.status === "fulfilled") { + * assertEquals(settled.a.value, "ok"); + * } + * if (settled.b.status === "rejected") { + * assertExists(settled.b.reason); + * } + * ``` + */ +export function allSettledKeyed>( + record: PromiseRecord, +): Promise> { + const keys = getEnumerableKeys(record); + const values = keys.map((key) => record[key as keyof typeof record]); + + return Promise.allSettled(values).then((settled) => { + const result = Object.create(null) as SettledRecord; + for (let i = 0; i < keys.length; i++) { + result[keys[i] as keyof T] = settled[i] as PromiseSettledResult< + T[keyof T] + >; + } + return result; + }); +} diff --git a/async/unstable_all_keyed_test.ts b/async/unstable_all_keyed_test.ts new file mode 100644 index 000000000000..4c9d1b0586a8 --- /dev/null +++ b/async/unstable_all_keyed_test.ts @@ -0,0 +1,197 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { allKeyed, allSettledKeyed } from "./unstable_all_keyed.ts"; +import { + assertEquals, + assertFalse, + assertRejects, + assertStrictEquals, +} from "@std/assert"; + +// allKeyed tests + +Deno.test("allKeyed() resolves record of promises", async () => { + const result = await allKeyed({ + a: Promise.resolve(1), + b: Promise.resolve("two"), + c: Promise.resolve(true), + }); + + assertEquals(result, { a: 1, b: "two", c: true }); +}); + +Deno.test("allKeyed() handles mixed promises and plain values", async () => { + const result = await allKeyed({ + promise: Promise.resolve(42), + plain: "static", + anotherPromise: Promise.resolve([1, 2, 3]), + }); + + assertEquals(result, { + promise: 42, + plain: "static", + anotherPromise: [1, 2, 3], + }); +}); + +Deno.test("allKeyed() handles empty record", async () => { + const result = await allKeyed({}); + assertEquals(result, {}); +}); + +Deno.test("allKeyed() rejects on first rejection", async () => { + const error = new Error("test error"); + + await assertRejects( + () => + allKeyed({ + a: Promise.resolve(1), + b: Promise.reject(error), + c: Promise.resolve(3), + }), + Error, + "test error", + ); +}); + +Deno.test("allKeyed() preserves symbol keys", async () => { + const sym = Symbol("test"); + const result = await allKeyed({ + [sym]: Promise.resolve("symbol value"), + regular: Promise.resolve("regular value"), + }); + + assertEquals(result[sym], "symbol value"); + assertEquals(result.regular, "regular value"); +}); + +Deno.test("allKeyed() ignores non-enumerable properties", async () => { + const record = Object.create(null); + Object.defineProperty(record, "enumerable", { + value: Promise.resolve("visible"), + enumerable: true, + }); + Object.defineProperty(record, "nonEnumerable", { + value: Promise.resolve("hidden"), + enumerable: false, + }); + + const result = await allKeyed(record); + + assertEquals(result, { enumerable: "visible" }); + assertEquals(Object.keys(result), ["enumerable"]); +}); + +// allSettledKeyed tests + +Deno.test("allSettledKeyed() resolves all promises", async () => { + const result = await allSettledKeyed({ + a: Promise.resolve(1), + b: Promise.resolve("two"), + }); + + assertEquals(result, { + a: { status: "fulfilled", value: 1 }, + b: { status: "fulfilled", value: "two" }, + }); +}); + +Deno.test("allSettledKeyed() handles mixed fulfilled and rejected", async () => { + const error = new Error("rejection reason"); + const result = await allSettledKeyed({ + success: Promise.resolve("ok"), + failure: Promise.reject(error), + }); + + assertEquals(result.success, { status: "fulfilled", value: "ok" }); + assertEquals(result.failure.status, "rejected"); + assertEquals((result.failure as PromiseRejectedResult).reason, error); +}); + +Deno.test("allSettledKeyed() handles all rejections without throwing", async () => { + const error1 = new Error("error 1"); + const error2 = new Error("error 2"); + + const result = await allSettledKeyed({ + a: Promise.reject(error1), + b: Promise.reject(error2), + }); + + assertEquals(result.a.status, "rejected"); + assertEquals(result.b.status, "rejected"); + assertEquals((result.a as PromiseRejectedResult).reason, error1); + assertEquals((result.b as PromiseRejectedResult).reason, error2); +}); + +Deno.test("allSettledKeyed() handles empty record", async () => { + const result = await allSettledKeyed({}); + assertEquals(result, {}); +}); + +Deno.test("allSettledKeyed() preserves symbol keys", async () => { + const sym = Symbol("test"); + const result = await allSettledKeyed({ + [sym]: Promise.resolve("symbol"), + regular: Promise.reject(new Error("fail")), + }); + + assertEquals(result[sym], { status: "fulfilled", value: "symbol" }); + assertEquals(result.regular.status, "rejected"); +}); + +Deno.test("allKeyed() returns object with null prototype", async () => { + const result = await allKeyed({ a: Promise.resolve(1) }); + + assertStrictEquals(Object.getPrototypeOf(result), null); + assertFalse("hasOwnProperty" in result); + assertFalse("toString" in result); +}); + +Deno.test("allSettledKeyed() returns object with null prototype", async () => { + const result = await allSettledKeyed({ a: Promise.resolve(1) }); + + assertStrictEquals(Object.getPrototypeOf(result), null); + assertFalse("hasOwnProperty" in result); + assertFalse("toString" in result); +}); + +Deno.test("allKeyed() preserves numeric key order", async () => { + const result = await allKeyed({ + "2": Promise.resolve("two"), + "1": Promise.resolve("one"), + "10": Promise.resolve("ten"), + }); + + assertEquals(Object.keys(result), ["1", "2", "10"]); + assertEquals(result["1"], "one"); + assertEquals(result["2"], "two"); + assertEquals(result["10"], "ten"); +}); + +Deno.test("allKeyed() ignores inherited properties", async () => { + const proto = { inherited: Promise.resolve("from proto") }; + const record = Object.create(proto); + record.own = Promise.resolve("own property"); + + const result = await allKeyed(record); + + assertEquals(Object.keys(result), ["own"]); + assertFalse("inherited" in result); +}); + +Deno.test("allKeyed() ignores non-enumerable symbol keys", async () => { + const sym = Symbol("hidden"); + const record: Record> = {}; + Object.defineProperty(record, sym, { + value: Promise.resolve("hidden"), + enumerable: false, + }); + Object.defineProperty(record, "visible", { + value: Promise.resolve("visible"), + enumerable: true, + }); + + const result = await allKeyed(record); + + assertEquals(Object.keys(result), ["visible"]); + assertFalse(sym in result); +});