Typescript recursive function composition

Circular type aliases are not really supported except in certain cases. (UPDATE TS 4.1, these are more supported now, but I’m still inclined to represent flow() as operating on AsChain that verifies a particular array of functions instead of trying to come up with a Chain that matches all valid arrays of functions)

Instead of trying to represent the specific type you’ve written there in a TypeScript-friendly way, I think I’ll back up and interpret your question as: how can we type a flow()-like function, which takes as its arguments a variable number of one-argument functions, where each one-argument-function return type is the argument type for the next one-argument-function, like a chain… and which returns a one-argument function representing the collapsed chain?

I’ve got something that I believe works, but it’s quite complicated, using a lot of conditional types, tuple spreads, and mapped tuples. Here it is:

type Lookup<T, K extends keyof any, Else=never> = K extends keyof T ? T[K] : Else

type Tail<T extends any[]> = T extends [any, ...infer R] ? R : never;

type Func1 = (arg: any) => any;
type ArgType<F, Else=never> = F extends (arg: infer A) => any ? A : Else;
type AsChain<F extends [Func1, ...Func1[]], G extends Func1[]= Tail<F>> =
  { [K in keyof F]: (arg: ArgType<F[K]>) => ArgType<Lookup<G, K, any>, any> };

type Last<T extends any[]> = T extends [...infer F, infer L] ? L : never;
type LaxReturnType<F> = F extends (...args: any) => infer R ? R : never;

declare function flow<F extends [(arg: any) => any, ...Array<(arg: any) => any>]>(
  ...f: F & AsChain<F>
): (arg: ArgType<F[0]>) => LaxReturnType<Last<F>>;

Let’s see if it works:

const stringToString = flow(
  (x: string) => x.length, 
  (y: number) => y + "!"
); // okay
const str = stringToString("hey"); // it's a string

const tooFewParams = flow(); // error

const badChain = flow(
  (x: number)=>"string", 
  (y: string)=>false, 
  (z: number)=>"oops"
); // error, boolean not assignable to number

Looks good to me.


I’m not sure if it’s worth it to go through in painstaking detail about how the type definitions work, but I might as well explain how to use them:

  • Lookup<T, K, Else> tries to return T[K] if it can, otherwise it returns Else. So Lookup<{a: string}, "a", number> is string, and Lookup<{a: string}, "b", number> is number.

  • Tail<T> takes a tuple type T and returns a tuple with the first element removed. So Tail<["a","b","c"]> is ["b","c"].

  • Func1 is just the type of a one-argument function.

  • ArgType<F, Else> returns the argument type of F if it’s a one-argument function, and Else otherwise. So ArgType<(x: string)=>number, boolean> is string, and ArgType<123, boolean> is boolean.

  • AsChain<F> takes a tuple of one-argument functions and tries to turn it into a chain, by replacing the return type of each function in F with the argument type of the next function (and using any for the last one). If AsChain<F> is compatible with F, everything’s good. If AsChain<F> is incompatible with F, then F is not a good chain. So, AsChain<[(x: string)=>number, (y:number)=>boolean]> is [(x: string)=>number, (y: number)=>any], which is good. But AsChain<[(x: string)=>number, (y: string)=>boolean]> is [(x: string)=>string, (y: string)=>any], which is not good.

  • Last<T> takes a tuple and returns the last element, which we need to represent the return type of flow(). Last<["a","b","c"]> is "c".

  • Finally, LaxReturnType<F> is just like ReturnType<F> but without a constraint on F.


Okay, hope that helps; good luck!

Playground link to code

Leave a Comment