英文:
Is it possible to extract generic arguments of a type as an array/tuple from an array/tuple of generic types?
问题
I have translated the code portions you provided. Here they are:
要创建一个函数,该函数接受一组通用函数,并将它们“zip”成一个单一函数,该函数接受所有输入函数的参数并在一次调用中返回所有函数的输出。我唯一能够使其类型安全的方法是使用`infer`来推断每个通用函数的各个组件:
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>;
然而,我想知道是否有更优雅和/或符合惯用法的方法来从给定的函数数组中提取函数的参数和返回类型作为元组。这里是一个(不工作的)示例,说明我的意思:
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;
编辑:我实际上想表达的是以下内容:
[Type<A1, B1>, Type<A2, B2>, ...etc] => Type<[A1, A2, ..etc], [B1, B2, ...etc]>
编辑:@jcalz提出的解决方案本来是完美的,如果TS可以正确推断第二个通用类型,但出于不明原因它不起作用:
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([
(x: string) => x.length,
(y: number) => y.toFixed(),
]);
希望这对你有所帮助。如果需要更多信息,请告诉我。
英文:
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(),
]);
答案1
得分: 1
以下是您要翻译的内容:
"The "elegant" answer to your question is to use two generic type parameters, corresponding to the tuple A
of function argument types and the tuple R
of their corresponding return types, and then the input fns
would be a mapped type over one of these (say, A
):
const fnZip = <A extends any[], R extends { [I in keyof A]: any }>(
fns: [...{ [I in keyof A]: Fn<A[I], R[I]> }],
): Fn<A, R> => (args) => fns.map((fn, idx) => fn(args[idx])) as R;
(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
and R
type arguments, you'll get the expected output:
const zipped = fnZip<[string, number], [number, string]>(
[x => x.length, y => y.toFixed(2)]
);
// const zipped: Fn<[string, number], [number, string]> 👍
But if you'd like the type arguments to be inferred, you're going to have a bad time:
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const zipped: Fn<[string, number], [any, any]> 👎
Here A
is inferred correctly but R
is not. That's because inference from mapped types only infers the type whose properties you're mapping over. Since fns
's type maps over the keys of A
, only A
can get properly inferred. R
ends up falling back to the constraint.
You can switch to R
instead of A
but that just moves the problem:
const fnZip = <A extends { [I in keyof R]: any }, R extends any[]>(
fns: [...{ [I in keyof R]: Fn<A[I], R[I]> }],
): Fn<A, R> => (args) => fns.map((fn, idx) => fn(args[idx])) as R;
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const zipped: Fn<[any, any], [number, string]> 👎
You could try to make it a mapped type over both A
and R
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:
const fnZip = <A extends { [I in keyof R]: any } & any[], R extends { [I in keyof A]: any }>(
fns: [...{ [I in keyof (A & R)]: Fn<A[I], R[I]> }], // error!
// -> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// A rest element type must be an array type.(2574)
): Fn<A, R> => (args) => fns.map((fn, idx) => fn(args[idx])) as R;
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const zipped: Fn<[string, number], [number, string]> 👍
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 parameter F
or [...F]
, and then spend a little effort unrolling that into A
and R
. Like you've done, or like this:
const fnZip = <F extends ((arg: any) => any)[]>(
fns: [...F]
): (args: { [I in keyof F]: Parameters<F[I]>[0] }) =>
fns.map((fn, idx) => fn(args[idx])) as
{ [I in keyof F]: ReturnType<F[I]> };
And it's a judgment call but I wouldn't say it's that inelegant. And it has the benefit of actually working:
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const z: (args: [string, number]) => [number, string] 😊
英文:
The "elegant" answer to your question is to use two generic type parameters, corresponding to the tuple A
of function argument types and the tuple R
of their corresponding return types, and then the input fns
would be a mapped type over one of these (say, A
):
const fnZip = <A extends any[], R extends { [I in keyof A]: any }>(
fns: [...{ [I in keyof A]: Fn<A[I], R[I]> }],
): Fn<A, R> => (args) => fns.map((fn, idx) => fn(args[idx])) as R;
(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
and R
type arguments, you'll get the expected output:
const zipped = fnZip<[string, number], [number, string]>(
[x => x.length, y => y.toFixed(2)]
);
// const zipped: Fn<[string, number], [number, string]> 👍
But if you'd like the type arguments to be inferred, you're going to have a bad time:
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const zipped: Fn<[string, number], [any, any]> 👎
Here A
is inferred correctly but R
is not. That's because inference from mapped types only infers the type whose properties you're mapping over. Since fns
's type maps over the keys of A
, only A
can get properly inferred. R
ends up falling back to the constraint.
You can switch to R
instead of A
but that just moves the problem:
const fnZip = <A extends { [I in keyof R]: any }, R extends any[]>(
fns: [...{ [I in keyof R]: Fn<A[I], R[I]> }],
): Fn<A, R> => (args) => fns.map((fn, idx) => fn(args[idx])) as R;
const zipped = fnZip2([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const zipped: Fn<[any, any], [number, string]> 👎
You could try to make it a mapped type over both A
and R
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:
const fnZip = <A extends { [I in keyof R]: any } & any[], R extends { [I in keyof A]: any }>(
fns: [...{ [I in keyof (A & R)]: Fn<A[I], R[I]> }], // error!
// -> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// A rest element type must be an array type.(2574)
): Fn<A, R> => (args) => fns.map((fn, idx) => fn(args[idx])) as R;
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const zipped: Fn<[string, number], [number, string]> 👍
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 parameter F
or [...F]
, and then spend a little effort unrolling that into A
and R
. Like you've done, or like this:
const fnZip = <F extends ((arg: any) => any)[]>(
fns: [...F]
) => (args: { [I in keyof F]: Parameters<F[I]>[0] }) =>
fns.map((fn, idx) => fn(args[idx])) as
{ [I in keyof F]: ReturnType<F[I]> };
And it's a judgment call but I wouldn't say it's that inelegant. And it has the benefit of actually working:
const zipped = fnZip([(x: string) => x.length, (y: number) => y.toFixed(2)]);
// const z: (args: [string, number]) => [number, string] 🙂
答案2
得分: 0
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!"
import { identity } from './identity';
import { UnaryFunction } from '../types';
export function pipe(): typeof identity;
export function pipe<T, A>(fn1: UnaryFunction<T, A>): UnaryFunction<T, A>;
export function pipe<T, A, B>(fn2: UnaryFunction<T, A>, fn2: UnaryFunction<A, B>): UnaryFunction<T, B>;
export function pipe<T, A, B, C>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, B>, fn3: UnaryFunction<B, C>): UnaryFunction<T, C>;
export function pipe<T, A, B, C, D>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>
): UnaryFunction<T, D>;
// ... (more overloaded functions) ...
/**
* pipe() can be called on one or more functions, each of which can take one argument ("UnaryFunction")
* and uses it to return a value.
* It returns a function that takes one argument, passes it to the first UnaryFunction, and then
* passes the result to the next one, passes that result to the next one, and so on.
*/
export function pipe(...fns: Array<UnaryFunction<any, any>>): UnaryFunction<any, any> {
return pipeFromArray(fns);
}
/** @internal */
export function pipeFromArray<T, R>(fns: Array<UnaryFunction<T, R>>): UnaryFunction<T, R> {
if (fns.length === 0) {
return identity as UnaryFunction<any, any>;
}
if (fns.length === 1) {
return fns[0];
}
return function piped(input: T): R {
return fns.reduce((prev: any, fn: UnaryFunction<T, R>) => fn(prev), input as any);
};
}
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.
英文:
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!"
import { identity } from './identity';
import { UnaryFunction } from '../types';
export function pipe(): typeof identity;
export function pipe<T, A>(fn1: UnaryFunction<T, A>): UnaryFunction<T, A>;
export function pipe<T, A, B>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, B>): UnaryFunction<T, B>;
export function pipe<T, A, B, C>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, B>, fn3: UnaryFunction<B, C>): UnaryFunction<T, C>;
export function pipe<T, A, B, C, D>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>
): UnaryFunction<T, D>;
export function pipe<T, A, B, C, D, E>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>,
fn5: UnaryFunction<D, E>
): UnaryFunction<T, E>;
export function pipe<T, A, B, C, D, E, F>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>,
fn5: UnaryFunction<D, E>,
fn6: UnaryFunction<E, F>
): UnaryFunction<T, F>;
export function pipe<T, A, B, C, D, E, F, G>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>,
fn5: UnaryFunction<D, E>,
fn6: UnaryFunction<E, F>,
fn7: UnaryFunction<F, G>
): UnaryFunction<T, G>;
export function pipe<T, A, B, C, D, E, F, G, H>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>,
fn5: UnaryFunction<D, E>,
fn6: UnaryFunction<E, F>,
fn7: UnaryFunction<F, G>,
fn8: UnaryFunction<G, H>
): UnaryFunction<T, H>;
export function pipe<T, A, B, C, D, E, F, G, H, I>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>,
fn5: UnaryFunction<D, E>,
fn6: UnaryFunction<E, F>,
fn7: UnaryFunction<F, G>,
fn8: UnaryFunction<G, H>,
fn9: UnaryFunction<H, I>
): UnaryFunction<T, I>;
export function pipe<T, A, B, C, D, E, F, G, H, I>(
fn1: UnaryFunction<T, A>,
fn2: UnaryFunction<A, B>,
fn3: UnaryFunction<B, C>,
fn4: UnaryFunction<C, D>,
fn5: UnaryFunction<D, E>,
fn6: UnaryFunction<E, F>,
fn7: UnaryFunction<F, G>,
fn8: UnaryFunction<G, H>,
fn9: UnaryFunction<H, I>,
...fns: UnaryFunction<any, any>[]
): UnaryFunction<T, unknown>;
/**
* pipe() can be called on one or more functions, each of which can take one argument ("UnaryFunction")
* and uses it to return a value.
* It returns a function that takes one argument, passes it to the first UnaryFunction, and then
* passes the result to the next one, passes that result to the next one, and so on.
*/
export function pipe(...fns: Array<UnaryFunction<any, any>>): UnaryFunction<any, any> {
return pipeFromArray(fns);
}
/** @internal */
export function pipeFromArray<T, R>(fns: Array<UnaryFunction<T, R>>): UnaryFunction<T, R> {
if (fns.length === 0) {
return identity as UnaryFunction<any, any>;
}
if (fns.length === 1) {
return fns[0];
}
return function piped(input: T): R {
return fns.reduce((prev: any, fn: UnaryFunction<T, R>) => fn(prev), input as any);
};
}
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.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论