skip to Main Content

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


  1. 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.

    Login or Signup to reply.
  2. 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] 🙂
    

    Playground link to code

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search