2

I want to write a generic function that accepts variable number of arguments that may have different types and returns a tuple based on those arguments.

Here is an example in JavaScript:

function evaluate (...fns) {
  return fns.map(fn => fn())
}

evaluate(
  () => 10
) // [ 10 ]

evaluate(
  () => 10,
  () => 'f',
  () => null
) // [ 10, 'f', null ]

And in TypeScript I need to somehow convert the spread argument tuple to a resulting one:

function evaluate<T1, T2 ... Tn> (
  ...fns: [() => T1, () => T2 ... () => Tn]
): [T1, T2 ... Tn] {
  return fns.map(fn => fn()) as [T1, T2 ... Tn]
}

evaluate(
  () => 10
) // [ 10 ]: [number]

evaluate(
  () => 10,
  () => 'f',
  () => null
) // [ 10, 'f', null ]: [number, string, null]

I've tried a naive approach of creating an overload for all reasonable lengths of tuple:

function evaluate<T1> (
  fn1: () => T1
): [T1]
function evaluate<T1, T2> (
  fn1: () => T1,
  fn2: () => T2
): [T1, T2]
function evaluate<T1, T2, T3> (
  fn1: () => T1,
  fn2: () => T2,
  fn3: () => T3
): [T1, T2, T3]
function evaluate<T1, T2, T3> (
  ...fns: Array<(() => T1) | (() => T2) | (() => T3)>
): [T1] | [T1, T2] | [T1, T2, T3] {
  return fns.map(fn => fn()) as [T1] | [T1, T2] | [T1, T2, T3]
}

But it looks horribly, doesn't scale well and causes issues with a more complex function body.

Is there any way this could be done dynamically? Thanks!

kiraind
  • 23
  • 1
  • 4
  • Yes, you can use mapped types on tuple/arrays as in [this approach](https://tsplay.dev/WJqd5m). Does that meet your needs? If so I can write up an answer explaining it. If not, what am I missing? – jcalz May 02 '22 at 19:08
  • Yes, that definitely meets my needs, thank you! Answer with an explanation would be useful too – kiraind May 02 '22 at 19:14

1 Answers1

4

The easiest way to implement this is to make evaluate() generic in its arraylike output type T (intended to be a tuple type), and then represent the fns rest parameter as a mapped type on T, noting that mapped array/tuple types are also array/tuple types:

function evaluate<T extends any[]>(
  ...fns: { [I in keyof T]: () => T[I] }
) {
  return fns.map(fn => fn()) as T;
}

Note that the type assertion as T is necessary because the compiler cannot see that fns.map(fn => fn()) will have the effect of converting an array/tuple of function types to the array/tuple of corresponding return types. See Mapping tuple-typed value to different tuple-typed value without casts for more information.

Because {[I in keyof T]: () => T[I]} is a homomorphic mapped type where we are mapping directly over keyof T (see What does "homomorphic mapped type" mean? for more information), the compiler is able to infer T from it (linked page is deprecated, but still accurate and no new page exists ‍♂️).

Let's see it in action:

const x = evaluate(() => 10);
// const x: [number]

const y = evaluate(
  () => 10,
  () => 'f',
  () => null
)
// const y: [number, string, null]

Looks good. The compiler sees that x is of type [number] and y is of type [number, string, null]. It also behaves reasonably in cases where you pass in a rest argument of unknown order/length:

const fs = [() => "a", () => 3];
// const fs: ((() => string) | (() => number))[]

const z = evaluate(...fs);
// const z: (string | number)[]

Here fs is of the type Array<(()=>string) | (()=>number)>, and so z is of the analogous type Array<string | number>.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360