I'm having trouble figuring out the correct way to type a variadic tuple type (I think that's the correct terminology 😅) with two generic arguments.
Typescript seems to be able to create a mapped type from a generic rest argument of the function to another type just fine, and infer the types of the arguments to a function within that type no problem. But, I'm running into issues when that "other" type requires two generic arguments, not just one.
I think the problem is best explained through a minimal example, which is below. The general use case is for a tuple of API handlers whose entry point is a single cloud function. All the handlers need to share some common logic before the handler is run, and need to validate/match their inputs against the request body, path parameters, and/or query string parameters to determine the correct handler to run.
Please also feel free to correct me if my terminology is wrong about generic arguments or arrays vs. tuples, etc...
/**
* The idea is to design a tuple of API handlers that could validate the request body against a validator object, `body`,
* and then take that strongly typed argument to do whatever it needs to do. This is useful because these API handlers need
* to all use some common auth logic, for example, or I want a single API route to be able to do a bunch of different things depending on the request.
*/
/** With one generic argument to this type, the following works: */
type OneGenericInput<Body extends object> = {
body: Body;
handler: (body: Body) => void;
};
const callableGenericInputs1 = <Inputs extends object[]>(...args: { [K in keyof Inputs]: OneGenericInput<Inputs[K]> }) => {
// do the matching and validation, call the appropriate handler here...
};
const test1 = callableGenericInputs1(
{
body: { test: "" },
/** `body` is correctly typed when I hover over it: `{ test: string }` */
handler: (body) => {
/** string functions work no problem. */
return body.test.substring(0);
}
}
);
/**
* With two generic arguments to the type, I'm not sure how to type it correctly, if it can even be done at all.
* For example, maybe I want to validate an object representing the path or query string parameters as well, and combining
* the body, path, and query string object all into one before validation isn't an option because the body type should be the body type
* and the query params type should be the query params type for security or whatnot.
*/
type TwoGenericInputs<Body extends object, Query extends object> = {
body: Body;
query: Query;
handler: (body: Body, query: Query) => void;
};
/**
* Compiler doesn't like this, and this actually doesn't even make sense to me,
* there aren't multiple rest arguments that could be inferred as tuples to the function.
*/
const callableGenericInputs2_1 = <
InputsBody extends object[],
InputsQuery extends object[]
>(...args: { [K in keyof InputsBody]: TwoGenericInputs<InputsBody[K], InputsQuery[K]> }) => {};
/**
* Complier doesn't complain, but in test2 `a` and `b` are typed as `object`.
*/
const callableGenericInputs2_2 = <
Inputs extends [object, object][]
>(...args: { [K in keyof Inputs]: TwoGenericInputs<Inputs[K][0], Inputs[K][1]> }) => {};
const test2 = callableGenericInputs2_2(
{
body: { test: "" },
query: { test: 0 },
handler: (body, query) => {
body.test; // ERROR: `Property 'test' does not exist on type 'object'`.
query.test;
}
}
)
I've seen a fair few other questions, like this one, but all the answers talk about mapping the tuple to generic types that only take one argument, which leads me to believe maybe this isn't possible :(
The other possible solutions to this problem I can think of is type assertions or extracting the type inference out into the individual handlers (through a wrapper function, currying perhaps (?), not too sure tbh...)
Thanks!
TypeScript supports generic inference from homomorphic mapped types. That is, the compiler can often infer T
from a value of type {[K in keyof T]: ...F<T[K]>...}
. (There is documentation for homomorphic mapped types here, but that page has been deprecated without any obvious non-deprecated version. Anyway you can read more about it at What does "homomorphic mapped type" mean? )
And since mapped types on array/tuple types produce other array/tuple types, the compiler can infer array/tuple types from homomorphic mapped types as well, as you show in callableGenericInputs1()
, inferring Inputs
from { [K in keyof Inputs]: OneGenericInput<Inputs[K]> }
.
But you're trying to infer two generic array/tuple types from a single argument, and it doesn't work how you want.
My suggestion here is to make your array an intersection of homomorphic mapped types, where one type maps over the keys of one of the generics, and the other maps over the keys of the other. It could look like this:
const callableGenericInputs2 = <
B extends object[],
Q extends object[]
>(...args:
{ [I in keyof B]: TwoGenericInputs<B[I], Q[I]> } &
{ [I in keyof Q]: TwoGenericInputs<B[I], Q[I]> }
) => { };
So the compiler will try to infer B
from the first intersection member, and Q
from the second intersection member. This more or less works, but there's a wrinkle: when you map I
over keyof B
, the compiler doesn't see the index I
as being keyof Q
, and vice versa. You have to work around that somehow. One way to do it is to write a helper type that does a conditional indexed access:
type Idx<T, K> = K extends keyof T ? T[K] : never
const callableGenericInputs2 = <
B extends object[],
Q extends object[]
>(...args:
{ [I in keyof B]: TwoGenericInputs<B[I], Idx<Q, I>> } &
{ [I in keyof Q]: TwoGenericInputs<Idx<B, I>, Q[I]> }
) => { };
So Idx<T, K>
will be equivalent to T[K]
if K
is a key of T
. So now the above compiles. Note that a conditional type like Idx<T, K>
wouldn't let you easily infer T
or K
from it... but luckily we only want to infer B
in the part that uses Idx<Q, I>
, and we only want to infer Q
in the part that uses Idx<B, I>
; so this doesn't cause problems.
Okay, let's try it out:
const test2 = callableGenericInputs2(
{
body: { test: "" },
query: { test: 0 },
handler: (body, query) => {
body.test.toUpperCase();
query.test.toFixed();
}
},
{
body: { a: "" },
query: { e: new Date() },
handler: (body, query) => {
body.a.toLowerCase();
query.e.getFullYear();
}
},
)
/* const callableGenericInputs2: <
[{ test: string; }, { a: string; }],
[{ test: number; }, { e: Date; }]> */
That works as desired. The B
type is inferred as [{test: string}, {a: string}]
, and the Q
type is inferred as [{test: number}, {e: Date}]
, and the inference happens in time to contextually type the body
and query
callback parameters in the handler
members.