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 QueryResult
s 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
.