Search code examples
javascripttypescriptpromisetyping

Return type of function always "Promise<unknown>"; not inferring correctly


The following function is supposed to create a number of promises and offset those promises in batches so that the first batch always resolves before the second, etc.

It returns the promises as a flat array of promises so that the caller can get the value of each promise as it evaluates, not waiting to the end to get the all values at once.

The following function does this fine, other than the fact that wherever I call it (and with whatever parameters), the return value is always automatically-typed as Promise<unknown>[]. Ie. it always automatically sets R as unknown.

/**
 * This function will take an array of functions that return
 * promises and execute them in batches of a given size. Instead of
 * returning the promises combined into a single promise, it will
 * return each promise individually as a flat array of promises but
 * will ensure that the promises are executed in batches of 'batchSize'.
 * This is useful when you want promises to run in batches but also
 * want to be able to access the results of each promise as its resolves.
 * Failure of any promise will not stop the execution of the other promises.
 * @param funcs the async functions to execute
 * @param batchSize the size of each batch
 * @returns an array of promises that will resolve in batches
 */
export function runPromisesInBatches<R, F extends () => Promise<R>>(
  funcs: Array<F>,
  batchSize: number,
): Array<Promise<R>> {
  /**
   * The current batch of promises being executed.
   * This will be reset when the batch size is reached.
   */
  let currentBatch: Promise<R>[] = [];
  /**
   * The list of batches that have been executed.
   */
  const batches: Promise<PromiseSettledResult<Awaited<R>>[]>[] = [];

  const wrappedPromises = funcs.map((f, i) => {
    /**
     * If the batch size has been reached, create a new batch
     * and push the current batch into the list of batches.
     * This will allow the promises to be executed
     * in batches of 'batchSize'.
     */
    if (i % batchSize === 0 && currentBatch.length) {
      const b = Promise.allSettled([...currentBatch]);
      batches.push(b);
      currentBatch = [];
    }

    /**
     * Delay the execution of the promise until the last batch has resolved
     * if that is a last batch (eg. the first batch will not wait for anything).
     */
    const lastBatch = batches.at(-1);
    let promise: Promise<R>;
    console.log("BATCH", !!lastBatch);
    if (lastBatch) {
      promise = lastBatch.then(() => f()).catch(() => f());
    } else {
      promise = f();
    }
    currentBatch.push(promise);

    return promise;
  });

  return wrappedPromises;
}

// Example usage:
(async () => {
  const tasks : (() => Promise<string>)[] = [
    () => new Promise<string>((resolve) => setTimeout(() => resolve('Task 1'), 3000)),
    () => new Promise<string>((_, reject) => setTimeout(() => reject('Task 3 Error'), 8000)),
    () => new Promise<string>((resolve) => setTimeout(() => resolve('Task 2'), 2000)),
    () => new Promise<string>((resolve) => setTimeout(() => resolve('Task 4'), 1000)),
  ];

  try {
    // XXX: results should be an array of Promise<string> but, instead, Promise<unknown>
    const results = runPromisesInBatches(tasks, 2)
    results.forEach(r => {
      r.then(d => console.log(d))
    })
    console.log('Results:', results);
  } catch (error) {
    console.error('Error in batch processing:', error);
  }
})();

what am I doing wrong? Why can't typescript infer the type?


Solution

  • Generic constraints are not inference sites for other generic type parameters. So if you have a function signature like function f<T, U extends F<T>>(u: U): void, and you call f(u), there is nowhere from which T can be inferred. TypeScript will infer that U is the type of u, but it doesn't then decide that T should be whatever makes U extends F<T> true. There is an old suggestion at microsoft/TypeScript#7234 to make constraints into inference sites, but it was declined in favor of supporting intersections. So instead of function f<T, U extends F<T>>(u: U): void, you'd want something like function f<T, U>(u: F<T> & U): void instead, and then f(u) could possibly infer both U (directly) and T (indirectly). Of course if you're only trying to infer U because you thought it was necessary to infer T, you might as well just leave that out entirely and write function f<T>(u: F<T>): void.

    So for your example code, I'd cut out F entirely and just use () => Promise<R> in its place:

    declare function runPromisesInBatches<R>(
      funcs: Array<() => Promise<R>>,
      batchSize: number,
    ): Array<Promise<R>>;
    

    and then it should work as expected:

    const results = runPromisesInBatches(tasks, 2);
    // const results: Promise<string>[]
    

    Here R has been inferred as string, as desired.

    Playground link to code