Home > Software design >  How do I create a new tuple from a variadic generic tuple in TypeScript?
How do I create a new tuple from a variadic generic tuple in TypeScript?

Time:02-26

Given:

class Foo {}
class Bar {}

interface QueryResult<T> {
  data: T;
}

function creatQueryResult<T>(data: T): QueryResult<T> {
  return {data};
}

function tuple<T extends any[]>(...args: T): T {
  return args;
}

I want to create and type a function using inferred types that accepts QueryResult[] or a tuple of QueryResults and a factory callback that picks data and invokes the callback like:

function createCompoundResult<T>(
  queryResults: T,
  callback: (queryResultDatas: ???<T>) => any
) {
  const datas = queryResults.map((queryResult) => queryResult.data);

  return callback(datas);
}

Note the ??? in the above code.

Usage:

const fooQueryResult = creatQueryResult(new Foo());
const barQueryResult = creatQueryResult(new Bar());

// Maybe using tuples are wrong?
const queryResults = tuple(fooQueryResult, barQueryResult);

createCompoundResult(
  queryResults,
  (datas) => {
    const [foo, bar] = datas;
    // foo should be inferred as Foo here and bar as Bar
  }
);

Maybe tuples are the wrong way to go? How would you solve it?

I'm a bit of a TypeScript newbie and I have a really hard time understanding stuff like keyof, extends keyof, { [K in keyof T]: { a: T[K] } } so if your solutions include arcane magic like this, please explain it to me like I'm 5 years old.

CodePudding user response:

My suggestion for createCompoundResult() is this:

function createCompoundResult<T extends any[]>(
  queryResults: readonly [...{ [I in keyof T]: QueryResult<T[I]> }],
  callback: (queryResultDatas: readonly [...T]) => any
) {
  const datas = queryResults.map((queryResult) => queryResult.data) as T;
  return callback(datas);
}

The function is generic in T, corresponding to the tuple of arguments to callback. In order to describe the type of queryResults in terms of the array/tuple type T, we want to map it to another array/tuple type where for each numeric index I of T, the element type T[I] gets mapped to QueryResult<T[I]>. So if T is [string, number], then we want queryResults to be of type [QueryResult<string>, QueryResult<number>].

You can do this via a mapped type. It looks like { [I in keyof T]: QueryResult<T[I]> }. For array-like generic types T, mapped types like [I in keyof T] only iterate over the numeric-like keys I (and skip all the other array keys like "push" and "length"). So you can imagine { [I in keyof T]: QueryResult<T[I]> } acting on a T of [string, boolean] operating on I being "0" and then "1", and T["0"] is string and T["1"] is boolean, so you get {0: QueryResult<string>, 1: QueryResults<boolean>}, which is magically interpreted as a new tuple type [QueryResult<string>, QueryResult<boolean>].

That's the main explanation, although there are a few outstanding things to mention.


First is that the compiler does not know that the array map() method will turn a tuple into a tuple, and it definitely doesn't know that queryResult => queryResult.data will turn a tuple of type { [I in keyof T]: QueryResult<T[I]> } into a tuple of type T. (See this question for more info.) It sees the output type of your queryResults.map(...) line as T[number][], meaning: some array of the element types of T. It has lost length and order information. So we have to use a type assertion to tell the compiler that the output of queryResults.map(...) is of type T, so that datas can be passed to callback.

Next, there are a few places where I've wrapped an array type AAA in readonly [...AAA]. This uses variadic tuple type syntax to give the compiler a hint that we'd like it to infer tuple types instead of array types. If you don't use that, then something like [fooQueryResult, barQueryResult] will tend to be inferred as an array type Array<QueryResult<Foo> | QueryResult<Bar>> instead of the desired tuple type [QueryResult<Foo>, QueryResult<Bar>]. Using this syntax frees us from needing to use a tuple() helper function, at least if you pass the array literal directly.


Anyway, let's make sure it works:

class Foo { x = 1 }
class Bar { y = 2 }

createCompoundResult(
  [fooQueryResult, barQueryResult],
  (datas) => {
    const [foo, bar] = datas;
    foo.x
    bar.y
  }
);

Looks good. I gave some structure to Foo and Bar (it's always recommended to do so even for example code) and sure enough, the compiler understands that datas is a tuple whose first element is a Foo and whose second element is a Bar.

Playground link to code

  • Related