Repository: true-myth/true-myth
Pull request #960 (2025-03-01 02:13:43)

Bug: `all` and `any` should preserve order of types [forward port from v8.x]
> [!NOTE]
> This is #959 but ported forward to the `main` branch

When I originally implemented these, I was rusty on the relevant type- level shenanigans that would be required to make them type check, but an unrelated bit of TypeScript work reminded me that we can use variadic generics in exactly this kind of scenario: we “just” need to constrain the relevant type aliases to use `[...TheRelevantType]` instead of using `[number]` to index them as arrays, and then also update the implementations to preserve that ordering.

The runtime implication is small:

1. Use `Array.prototype.entries` to enumerate the tasks and thereby save which `Task` is resolving or rejecting at any given point in `all` or `any` respectively.

2. Pre-allocate an array with the same length of the original array of tasks, so that it can be filled in at the appropriate location. This implies that the array will be “sparse” during the lifecycle of the function unless or until all the tasks resolve or reject for `all` and `any` respectively. This will never be visible to the caller, as the array will be returned full *or* garbage collected as unused.

In most cases, I expect the potential additional allocation overhead to be irrelevant: if anything, we may end up allocating *smaller* arrays this way

This is a bug fix that *will* produce red squiggles, but of the sort the SemVer TS spec expressly allows: they represent code users can *delete*, because they *had* to do runtime checks before to figure out what the type was for each item in the array, i.e., where they previously might have ended up with a `Resolved<Array<string | number>>`, they now might end up with `Resolved<[string, number]>`, so `task.value[0]` will be statically known to be `string` rather than `string | number`.

I started by fixing it here as targeting the v8.x branch, because it is unexpected and does not match the types for `Promise`; here I am porting it forward to the `main` branch targeting the upcoming v9.0 release.



Patch:
diff --git a/src/task.ts b/src/task.ts
index e61fe4d..64092b6 100644
--- a/src/task.ts
+++ b/src/task.ts
@@ -684,7 +684,7 @@ export function timer(ms: number): Timer {
   @internal
  */
 export type All<A extends readonly AnyTask[]> = Task<
-  Array<TaskTypesFor<A>[0][number]>,
+  [...TaskTypesFor<A>[0]],
   TaskTypesFor<A>[1][number]
 >;
 
@@ -730,21 +730,24 @@ export type All<A extends readonly AnyTask[]> = Task<
   @template A The type of the array or tuple of tasks.
 */
 export function all(tasks: []): Task<[], never>;
-export function all<A extends readonly AnyTask[]>(tasks: A): All<A>;
+// This overload uses a variadic generic `[...A]` in both parameter and return
+// position to correctly
+export function all<A extends readonly AnyTask[]>(tasks: readonly [...A]): All<[...A]>;
 export function all<A extends readonly AnyTask[]>(tasks: A): Task<unknown, unknown> {
   if (tasks.length === 0) {
     return Task.resolve([]);
   }
 
   let total = tasks.length;
-  let oks = new Array<unknown>();
+  let oks = Array.from({ length: tasks.length });
+  let resolved = 0;
   let hasRejected = false;
 
   return new Task((resolve, reject) => {
     // Because all tasks will *always* resolve, we need to manage this manually,
     // rather than using `Promise.all`, so that we produce a rejected `Task` as
     // soon as *any* `Task` rejects.
-    for (let task of tasks) {
+    for (let [idx, task] of tasks.entries()) {
       // Instead, each `Task` wires up handlers for resolution and rejection.
       task.match({
         // If it rejected, then check whether one of the other tasks has already
@@ -771,8 +774,9 @@ export function all<A extends readonly AnyTask[]>(tasks: A): Task<unknown, unkno
             return;
           }
 
-          oks.push(value);
-          if (oks.length === total) {
+          oks[idx] = value;
+          resolved += 1;
+          if (resolved === total) {
             resolve(oks);
           }
         },
@@ -873,8 +877,8 @@ export function allSettled(tasks: AnyTask[]): Task<unknown, never> {
   console.log(result.toString()); // Err(AggregateRejection: `Task.race`: 10ms,20ms,30ms)
   ```
 
-  (Note that the order in the resulting `AggregateRejection` is not guaranteed
-  to be stable!)
+  The order in the resulting `AggregateRejection` is guaranteed to be stable and
+  to match the order of the tasks passed in.
 
   @param tasks The set of tasks to check for any resolution.
   @returns A Task which is either {@linkcode Resolved} with the value of the
@@ -886,22 +890,24 @@ export function allSettled(tasks: AnyTask[]): Task<unknown, never> {
 */
 export function any(tasks: []): Task<never, AggregateRejection<[]>>;
 export function any<A extends readonly AnyTask[]>(
-  tasks: A
-): Task<TaskTypesFor<A>[0][number], AggregateRejection<Array<TaskTypesFor<A>[1][number]>>>;
-export function any(tasks: [] | AnyTask[]): AnyTask {
+  tasks: readonly [...A]
+): Task<TaskTypesFor<A>[0][number], AggregateRejection<[...TaskTypesFor<A>[1]]>>;
+// export function all<A extends readonly AnyTask[]>(tasks: readonly [...A]): All<[...A]>;
+export function any(tasks: readonly [] | readonly AnyTask[]): AnyTask {
   if (tasks.length === 0) {
     return Task.reject(new AggregateRejection([]));
   }
 
   let total = tasks.length;
   let hasResolved = false;
-  let rejections = new Array<unknown>();
+  let rejections = Array.from({ length: tasks.length });
+  let rejected = 0;
 
   return new Task((resolve, reject) => {
     // We cannot use `Promise.any`, because it will only return the first `Task`
     // that resolves, and the `Promise` for a `Task` *always* either resolves if
     // it settles.
-    for (let task of tasks) {
+    for (let [idx, task] of tasks.entries()) {
       // Instead, each `Task` wires up handlers for resolution and rejection.
       task.match({
         // If it resolved, then check whether one of the other tasks has already
@@ -928,9 +934,10 @@ export function any(tasks: [] | AnyTask[]): AnyTask {
             return;
           }
 
-          rejections.push(reason);
+          rejections[idx] = reason;
+          rejected += 1;
 
-          if (rejections.length === total) {
+          if (rejected === total) {
             reject(new AggregateRejection(rejections));
           }
         },


Test patch:
diff --git a/test/task.test.ts b/test/task.test.ts
index 5096d81..a4f1d0e 100644
--- a/test/task.test.ts
+++ b/test/task.test.ts
@@ -865,15 +865,15 @@ describe('module-scope functions', () => {
     describe('with a single task', () => {
       test('that is still pending', () => {
         let { task } = Task.withResolvers<string, number>();
-        let result = all([task] as const);
-        expectTypeOf(result).toEqualTypeOf<Task<Array<string>, number>>();
+        let result = all([task]);
+        expectTypeOf(result).toEqualTypeOf<Task<[string], number>>();
         expect(result.state).toBe(State.Pending);
       });
 
       test('that has resolved', async () => {
         let theTask = Task.resolve('hello');
         let result = all([theTask]);
-        expectTypeOf(result).toEqualTypeOf<Task<string[], never>>();
+        expectTypeOf(result).toEqualTypeOf<Task<[string], never>>();
         await result;
         expect(result.state).toBe(State.Resolved);
         if (result.isResolved) {
@@ -886,7 +886,7 @@ describe('module-scope functions', () => {
         let theTask = Task.reject<string, string>(theReason);
         let result = all([theTask]);
         await result;
-        expectTypeOf(result).toEqualTypeOf<Task<string[], string>>();
+        expectTypeOf(result).toEqualTypeOf<Task<[string], string>>();
         expect(result.state).toBe(State.Rejected);
         if (result.isRejected) {
           expect(result.reason).toBe(theReason);
@@ -898,7 +898,7 @@ describe('module-scope functions', () => {
       test('types', () => {
         let { task: task1 } = Task.withResolvers<string, number>();
         let { task: task2 } = Task.withResolvers<boolean, Error>();
-        let fromTuple = all([task1, task2] as const);
+        let fromTuple = all([task1, task2]);
         let fromArray = all([task1, task2]);
         expectTypeOf(fromTuple).toEqualTypeOf(fromArray);
       });
@@ -906,8 +906,8 @@ describe('module-scope functions', () => {
       test('that are all still pending', () => {
         let { task: task1 } = Task.withResolvers<string, number>();
         let { task: task2 } = Task.withResolvers<boolean, Error>();
-        let result = all([task1, task2] as const);
-        expectTypeOf(result).toEqualTypeOf<Task<Array<string | boolean>, number | Error>>();
+        let result = all([task1, task2]);
+        expectTypeOf(result).toEqualTypeOf<Task<[string, boolean], number | Error>>();
         expect(result.state).toBe(State.Pending);
       });
 
@@ -915,8 +915,8 @@ describe('module-scope functions', () => {
         test('while the second is pending', async () => {
           let { task: task1, resolve: resolve1 } = Task.withResolvers<string, number>();
           let { task: task2 } = Task.withResolvers<number, boolean>();
-          let result = all([task1, task2] as const);
-          expectTypeOf(result).toEqualTypeOf<Task<Array<string | number>, number | boolean>>();
+          let result = all([task1, task2]);
+          expectTypeOf(result).toEqualTypeOf<Task<[string, number], number | boolean>>();
 
           resolve1('first');
           expect(result.state).toBe(State.Pending);
@@ -926,14 +926,15 @@ describe('module-scope functions', () => {
           let { task: task1, resolve: resolve1 } = Task.withResolvers<number, string>();
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, boolean>();
           let result = all([task1, task2]);
-          expectTypeOf(result).toEqualTypeOf<Task<Array<number | string>, string | boolean>>();
+          expectTypeOf(result).toEqualTypeOf<Task<[number, string], string | boolean>>();
 
           resolve2('second');
           resolve1(1);
           await result;
           expect(result.state).toBe(State.Resolved);
           if (result.isResolved) {
-            expect(result.value).toEqual(['second', 1]);
+            expectTypeOf(result.value).toEqualTypeOf<[number, string]>();
+            expect(result.value).toEqual([1, 'second']);
           }
         });
 
@@ -941,7 +942,7 @@ describe('module-scope functions', () => {
           let { task: task1, resolve: resolve1 } = Task.withResolvers<string, number>();
           let { task: task2, reject: reject2 } = Task.withResolvers<boolean, string>();
           let result = all([task1, task2] as const);
-          expectTypeOf(result).toEqualTypeOf<Task<Array<string | boolean>, number | string>>();
+          expectTypeOf(result).toEqualTypeOf<Task<[string, boolean], number | string>>();
 
           reject2('error');
           resolve1('first');
@@ -958,7 +959,7 @@ describe('module-scope functions', () => {
           let { task: task1 } = Task.withResolvers<number, boolean>();
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, Error>();
           let result = all([task1, task2] as const);
-          expectTypeOf(result).toEqualTypeOf<Task<Array<number | string>, boolean | Error>>();
+          expectTypeOf(result).toEqualTypeOf<Task<[number, string], boolean | Error>>();
 
           resolve2('second');
           expect(result.state).toBe(State.Pending);
@@ -968,7 +969,7 @@ describe('module-scope functions', () => {
           let { task: task1, resolve: resolve1 } = Task.withResolvers<string, number>();
           let { task: task2, resolve: resolve2 } = Task.withResolvers<number, boolean>();
           let result = all([task1, task2] as const);
-          expectTypeOf(result).toEqualTypeOf<Task<Array<string | number>, number | boolean>>();
+          expectTypeOf(result).toEqualTypeOf<Task<[string, number], number | boolean>>();
 
           resolve1('first');
           resolve2(2);
@@ -983,7 +984,7 @@ describe('module-scope functions', () => {
           let { task: task1, reject: reject1 } = Task.withResolvers<number, string>();
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, boolean>();
           let result = all([task1, task2] as const);
-          expectTypeOf(result).toEqualTypeOf<Task<Array<number | string>, string | boolean>>();
+          expectTypeOf(result).toEqualTypeOf<Task<[number, string], string | boolean>>();
 
           reject1('error');
           resolve2('second');
@@ -1000,7 +1001,7 @@ describe('module-scope functions', () => {
       let { task: task1, reject: reject1 } = Task.withResolvers<number, string>();
       let { task: task2 } = Task.withResolvers<string, boolean>();
       let result = all([task1, task2] as const);
-      expectTypeOf(result).toEqualTypeOf<Task<Array<number | string>, string | boolean>>();
+      expectTypeOf(result).toEqualTypeOf<Task<[number, string], string | boolean>>();
 
       reject1('error');
       await result;
@@ -1266,7 +1267,7 @@ describe('module-scope functions', () => {
           let { task: task2 } = Task.withResolvers<number, boolean>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<string | number, AggregateRejection<Array<number | boolean>>>
+            Task<string | number, AggregateRejection<[number, boolean]>>
           >();
 
           resolve1('first');
@@ -1282,7 +1283,7 @@ describe('module-scope functions', () => {
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, boolean>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<number | string, AggregateRejection<Array<string | boolean>>>
+            Task<number | string, AggregateRejection<[string, boolean]>>
           >();
 
           resolve2('second');
@@ -1299,7 +1300,7 @@ describe('module-scope functions', () => {
           let { task: task2, reject: reject2 } = Task.withResolvers<boolean, string>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<string | boolean, AggregateRejection<Array<number | string>>>
+            Task<string | boolean, AggregateRejection<[number, string]>>
           >();
 
           reject2('error');
@@ -1318,7 +1319,7 @@ describe('module-scope functions', () => {
           let { task: task2 } = Task.withResolvers<number, boolean>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<string | number, AggregateRejection<Array<number | boolean>>>
+            Task<string | number, AggregateRejection<[number, boolean]>>
           >();
 
           reject1(1);
@@ -1330,7 +1331,7 @@ describe('module-scope functions', () => {
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, boolean>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<number | string, AggregateRejection<Array<string | boolean>>>
+            Task<number | string, AggregateRejection<[string, boolean]>>
           >();
 
           resolve2('second');
@@ -1347,7 +1348,7 @@ describe('module-scope functions', () => {
           let { task: task2, reject: reject2 } = Task.withResolvers<boolean, string>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<string | boolean, AggregateRejection<Array<number | string>>>
+            Task<string | boolean, AggregateRejection<[number, string]>>
           >();
 
           reject2('error');
@@ -1356,8 +1357,8 @@ describe('module-scope functions', () => {
           expect(result.state).toBe(State.Rejected);
           if (result.isRejected) {
             expect(result.reason).toBeInstanceOf(AggregateRejection);
-            expect(result.reason.errors[0]!).toBe('error');
-            expect(result.reason.errors[1]!).toBe(1);
+            expect(result.reason.errors[0]!).toBe(1);
+            expect(result.reason.errors[1]!).toBe('error');
           }
         });
       });
@@ -1368,7 +1369,7 @@ describe('module-scope functions', () => {
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, Error>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<number | string, AggregateRejection<Array<boolean | Error>>>
+            Task<number | string, AggregateRejection<[boolean, Error]>>
           >();
 
           resolve2('second');
@@ -1384,7 +1385,7 @@ describe('module-scope functions', () => {
           let { task: task2, resolve: resolve2 } = Task.withResolvers<number, boolean>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<string | number, AggregateRejection<Array<number | boolean>>>
+            Task<string | number, AggregateRejection<[number, boolean]>>
           >();
 
           resolve1('first');
@@ -1401,7 +1402,7 @@ describe('module-scope functions', () => {
           let { task: task2, resolve: resolve2 } = Task.withResolvers<string, boolean>();
           let result = any([task1, task2]);
           expectTypeOf(result).toEqualTypeOf<
-            Task<number | string, AggregateRejection<Array<string | boolean>>>
+            Task<number | string, AggregateRejection<[string, boolean]>>
           >();
 
           reject1('error');
@@ -1421,7 +1422,7 @@ describe('module-scope functions', () => {
         let { task: task2, reject: reject2 } = Task.withResolvers<string, number>();
         let result = any([task1, task2]);
         expectTypeOf(result).toEqualTypeOf<
-          Task<number | string, AggregateRejection<Array<boolean | number>>>
+          Task<number | string, AggregateRejection<[boolean, number]>>
         >();
 
         reject2(2);
@@ -1433,7 +1434,7 @@ describe('module-scope functions', () => {
         let { task: task2, reject: reject2 } = Task.withResolvers<number, boolean>();
         let result = any([task1, task2]);
         expectTypeOf(result).toEqualTypeOf<
-          Task<string | number, AggregateRejection<Array<number | boolean>>>
+          Task<string | number, AggregateRejection<[number, boolean]>>
         >();
 
         resolve1('first');
@@ -1450,7 +1451,7 @@ describe('module-scope functions', () => {
         let { task: task2, reject: reject2 } = Task.withResolvers<string, boolean>();
         let result = any([task1, task2] as const);
         expectTypeOf(result).toEqualTypeOf<
-          Task<number | string, AggregateRejection<Array<string | boolean>>>
+          Task<number | string, AggregateRejection<[string, boolean]>>
         >();
 
         reject1('error');