I want to create a function that takes an array of generic functions and "zips" them into a single function that takes all of the input functions’ arguments and returns all of the outputs of those functions in a single call. The only way I was able to make it type-safe is with infer
‘ing each generic functions’ individual component:
type Fn<T = any, TReturn = any> = (arg: T) => TReturn;
type FnArg<T extends Fn> = T extends Fn<infer TArg> ? TArg : unknown;
type FnArgTuple<T extends Fn[]> = { [key in keyof T]: FnArg<T[key]> };
type FnReturn<T extends Fn> = T extends Fn<any, infer TReturn> ? TReturn : unknown;
type FnReturnTuple<T extends Fn[]> = { [key in keyof T]: FnReturn<T[key]> };
const fnZip = <T extends ((arg: any) => any)[]>(
fns: [...T],
): Fn<FnArgTuple<T>, FnReturnTuple<T>> => (
args,
) => fns.map((fn, idx) => fn(args[idx])) as FnReturnTuple<T>;
However, I was wondering whether there was more elegant and/or idiomatic way to extract functions’ arguments and return types as tuples from a given array of functions. Here’s a (non-working) illustration of what I mean:
type Fn<T = any, TReturn = any> = (arg: T) => TReturn;
const fnZip = <T extends any[], TReturn extends any[]>(
fns: { [key: number]: Fn<T[key], TReturn[key]> },
): Fn<T, TReturn> => (args) => fns.map((fn, idx) => fn(args[idx])) as TReturn;
EDIT: What I effectively want to express is the following:
[Type<A1, B1>, Type<A2, B2>, ...etc] => Type<[A1, A2, ..etc], [B1, B2, ...etc]>
EDIT: @jcalz proposed a solution that would’ve worked perfectly if TS could infer the second generic type correctly, but it doesn’t for unknown reason:
type Fn<T = any, TReturn = any> = (arg: T) => TReturn;
const fnZip = <T extends any[], TReturn extends { [key in keyof T]: any }>(
fns: [...{ [key in keyof T]: Fn<T[key], TReturn[key]> }],
): Fn<T, TReturn> => (args) => fns.map((fn, idx) => fn(args[idx])) as TReturn;
const zipped = fnZip([
// ^? const zipped: Fn<[string, number], [any, any]>
(x: string) => x.length,
(y: number) => y.toFixed(),
]);
2
Answers
I think it’s safe to say the answer is no.
And I say that because Ben Lesh, and the brilliant people he works with to create and improve RXJS don’t have a great answer either despite RXJS being wildly popular, open source code.
So if there was an elegant solution, they’d adopt it.
Instead, (as of 7-MAY-2023), their solution is a brute-force method of overloading, 0 through 9 arguments, and then for the 10th argument onward the code is "now we’re getting silly so just make sure they’re single-parameter functions and call that good enough!"
My advice to you is to use RXJS’s solution instead of writing your own. If their code was insufficient then they’d have changed it. Also, if you’re already using RXJS as a dependency, or don’t mind including it, then you’ll get any improvements automatically.
The "elegant" answer to your question is to use two generic type parameters, corresponding to the tuple
A
of function argument types and the tupleR
of their corresponding return types, and then the inputfns
would be a mapped type over one of these (say,A
):(Well, it’s actually a mapped type wrapped in a variadic tuple type to prompt the compiler to infer a tuple type for
A
instead of some unordered array type of arbitrary length.)That version "works", in the sense that if you manually specify the
A
andR
type arguments, you’ll get the expected output:But if you’d like the type arguments to be inferred, you’re going to have a bad time:
Here
A
is inferred correctly butR
is not. That’s because inference from mapped types only infers the type whose properties you’re mapping over. Sincefns
‘s type maps over the keys ofA
, onlyA
can get properly inferred.R
ends up falling back to the constraint.You can switch to
R
instead ofA
but that just moves the problem:You could try to make it a mapped type over both
A
andR
at once somehow, but everything I tried either didn’t help or caused at least one compiler error somewhere, and the quotation marks around the word "elegant" were becoming more and more sarcastic as I tried different and weirder things. Here’s the closest I could get:Here the inference works, hooray! But the call signature causes a compiler error I can’t resolve without breaking the inference.
For now, this doesn’t quite look possible. Even if I found a way that worked I’d be worried that it’d be too fragile to rely on. This is at or beyond the edge of TypeScript’s abilities right now.
Instead I think it’s much more straightforward to make the inference work out by doing the simple thing of having
fns
‘s type be the type parameterF
or[...F]
, and then spend a little effort unrolling that intoA
andR
. Like you’ve done, or like this:And it’s a judgment call but I wouldn’t say it’s that inelegant. And it has the benefit of actually working:
Playground link to code