Home > front end >  Typing a reduce over a Typescript tuple
Typing a reduce over a Typescript tuple

Time:11-04

I'm trying to write a function that works in a similar way to Promise.all but instead of all the promises executing in parallel I'd like to pass an array of callbacks and execute them sequentially. This is the JS code I've written:

function promiseAllSequential(promises) {
  return promises.reduce(
    (promiseAccumulator, currentPromise) =>
      promiseAccumulator.then((resultAccumulator) =>
        currentPromise.then((currentResult) => [...resultAccumulator, currentResult])
      ),
    Promise.resolve([])
  );
}

The types for function signature for Promise.all look like this:

all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>;

Because I'd like my function to act in a similar way I think the signature should look like:

promiseAllSequential<FunctionsTuple extends readonly (() => unknown)[] | []>(
  functions: FunctionsTuple
): Promise<{
  -readonly [TupleIndex in keyof FunctionsTuple]: Awaited<ReturnType<FunctionsTuple[TupleIndex]>>;
}>

However, I am struggling to get these types too work with the code I've written. I think the main issue is how to type my reduce function when working with a tuple.

I've created a typescript playground which shows the errors. And a typescript playground which shows my best effort to resolve the errors using a lot of hacky assertions.

Is there a feature of typescript I could use to achieve this without having to resort to loads of type assertions?

CodePudding user response:

Not only can't TypeScript infer the types necessary to verify the correctness of what you're doing (using the reduce() array method to successively build up a tuple type which ends up as the mapped tuple type you are returning), there's not even a way to express that operation in general. The call signature for reduce() is currently only able to capture the effects of an accumulator of a single type. In order to express the general idea of an evolving accumulator type, you'd need higher kinded types of the sort requested in microsoft/TypeScript#1213. Something like:

// not valid TypeScript, don't try this
interface Array<T> {
  reduce<A extends any[], F<~ extends A['length'] | LessThan<A['length']>>>(
    this: A, 
    callbackfn: <I extends LessThan<A['length']>>(
      previousValue: F<I>, 
      currentValue: A[I], 
      currentIndex: I, 
      array: A
    ) => F<A['length']>, 
    initialValue: F<0>
  ): F<A['length'];
}

where F<~> is some hypothetical higher order type parameter where F can be substituted with a generic type of one type parameter (like Array) and LessThan<T> is a generic type producing a union of non-negative numbers less than its input (you actually can implement this, but that's out of scope here), and A is the tuple type of this.

All of that would at least express that callbackfn takes an accumulator of type F<0> and A[0] and produces a value of type F<1>, and takes an F<1> and A[1] and produces an F<2>, all the way to something that produces an F<A['length']>.

But there's no direct way to refer to a generic parameter that itself ranges over generic types F<~> without microsoft/TypeScript#1213. There are ways to simulate higher kinded types, but they are not simple to use and would make the above signature for reduce() even more ugly... and even so, I sincerely doubt the compiler would be able to infer F, you'd have to specify it, and then the compiler would probably not be able to understand that any particular callback implementation actually adheres to the required higher order call signature.

All of this is to say that this is well outside the capabilities of the compiler, probably for the foreseeable future. It's a similar (or maybe even the same) issue as what's going on in Is Typescript 4.0 capable of functions using mapped variadic tuple types?


So you're going to have to use at least one type assertion to loosen the type checking enough to prevent compiler errors (or you'll have to use a moral equivalent like overloads where the implementation is checked more loosely than non-overloaded implementation bodies).

So you didn't do anything wrong, per se. If you want, you could limit the assertions to just one, like this:

async function promiseAllSequential<F extends (() => unknown)[]>(
  functions: readonly [...F]
) {
  return functions.reduce(
    (promiseAccumulator, currentFunction) =>
      promiseAccumulator.then(async (resultAccumulator) => {
        const result = await currentFunction();
        return [...resultAccumulator, result];
      }),
    Promise.resolve<unknown[]>([])
  ) as Promise<{
    -readonly [I in keyof F]: Awaited<ReturnType<F[I]>>;
  }>;
}

Remarks:

  • I've removed the assertion on functions by giving it a single type instead of a union. Presumably you wrote F extends (()=>unknown)[] | [] as a hint for the compiler to infer a tuple type instead of a non-tuple array type. You can get the same effect by making functions of type [...F] instead of just F, using variadic tuple type notation, which (according to the implementing pull request at microsoft/TypeScript#39094) also "can conveniently be used to indicate a preference for inference of tuple types".

  • I've moved the annotation of promiseAccumulator: Promise<unknown[]> to just the generic type specification on Promise.resolve<unknown[]>([]). This doesn't do much other than save a few keystrokes.

  • I've removed the "intermediate" as unknown assertion, which isn't necessary here (it's only necessary in some situations where the compiler really has no idea that the asserted type is related to the value).

  • I've removed the return type annotation and just allow the compiler to infer the return type from the asserted return value.

But all of that is mostly just cosmetic; at its core, you have to just assert to the compiler what you're doing with the type, because it can't infer it for you, nor understand that what you're doing is safe.

Playground link to code

  • Related