Typing compose function in TypeScript

Let's write some crazy, unreadable and unmaintainable typings for compose function. Maybe you will learn smth new.

Let's define some base types and utils.

type Fn = (a: any) => any

type Head<T extends any[]> =
    T extends [infer H, ...infer _]
    ? H
    : never;

type Last<T extends any[]> =
    T extends [infer _]
    ? never : T extends [...infer _, infer Tl]
    ? Tl
    : never;

type Foo = typeof foo
type Bar = typeof bar
type Baz = typeof baz

Our main goal is to make compose without any arguments length restriction.

For example, take a look on lodash compose typings:

interface LodashFlowRight {
        <A extends any[], R1, R2, R3, R4, R5, R6, R7>(f7: (a: R6) => R7, f6: (a: R5) => R6, f5: (a: R4) => R5, f4: (a: R3) => R4, f3: (a: R2) => R3, f2: (a: R1) => R2, f1: (...args: A) => R1): (...args: A) => R7;
        <A extends any[], R1, R2, R3, R4, R5, R6>(f6: (a: R5) => R6, f5: (a: R4) => R5, f4: (a: R3) => R4, f3: (a: R2) => R3, f2: (a: R1) => R2, f1: (...args: A) => R1): (...args: A) => R6;
        <A extends any[], R1, R2, R3, R4, R5>(f5: (a: R4) => R5, f4: (a: R3) => R4, f3: (a: R2) => R3, f2: (a: R1) => R2, f1: (...args: A) => R1): (...args: A) => R5;
        <A extends any[], R1, R2, R3, R4>(f4: (a: R3) => R4, f3: (a: R2) => R3, f2: (a: R1) => R2, f1: (...args: A) => R1): (...args: A) => R4;
        <A extends any[], R1, R2, R3>(f3: (a: R2) => R3, f2: (a: R1) => R2, f1: (...args: A) => R1): (...args: A) => R3;
        <A extends any[], R1, R2>(f2: (a: R1) => R2, f1: (...args: A) => R1): (...args: A) => R2;
        (...func: Array<lodash.Many<(...args: any[]) => any>>): (...args: any[]) => any;
    }

There is a limit for arguments.

Let's try to write function without any limits, at least explicit limits. Please keep in mind TS has his own recursion limits, so we have to live with that

I will start with validation logic:

type Allowed<
    T extends Fn[],
    Cache extends Fn[] = []
    > =
    T extends []
    ? Cache
    : T extends [infer Lst]
    ? Lst extends Fn
    ? Allowed<[], [...Cache, Lst]> : never
    : T extends [infer Fst, ...infer Lst]
    ? Fst extends Fn
    ? Lst extends Fn[]
    ? Head<Lst> extends Fn
    ? Head<Parameters<Fst>> extends ReturnType<Head<Lst>>
    ? Allowed<Lst, [...Cache, Fst]>
    : never
    : never
    : never
    : never
    : never;

Above type iterates through every function in the array and checks if argument of current function is assignable to return type of next function Head<Parameters<Fst>> extends ReturnType<Head<Lst>>

Next, we can define simple helpers:

type LastParameterOf<T extends Fn[]> =
    Last<T> extends Fn
    ? Head<Parameters<Last<T>>>
    : never

type Return<T extends Fn[]> =
    Head<T> extends Fn
    ? ReturnType<Head<T>>
    : never

Finally, our compose function:

function compose<T extends Fn, Fns extends T[], Allow extends {
    0: [never],
    1: [LastParameterOf<Fns>]
}[Allowed<Fns> extends never ? 0 : 1]>
    (...args: [...Fns]): (...data: Allow) => Return<Fns>

function compose<
    T extends Fn,
    Fns extends T[], Allow extends unknown[]
>(...args: [...Fns]) {
    return (...data: Allow) =>
        args.reduceRight((acc, elem) => elem(acc), data)
}

As you might have noticed, I have defined only one overload, this is considered a bad practice. We should always define at least two. Sorry for that.

And full example for copy/paste:

type Foo = typeof foo
type Bar = typeof bar
type Baz = typeof baz


type Fn = (a: any) => any

type Head<T extends any[]> =
    T extends [infer H, ...infer _]
    ? H
    : never;

type Last<T extends any[]> =
    T extends [infer _]
    ? never : T extends [...infer _, infer Tl]
    ? Tl
    : never;


type Allowed<
    T extends Fn[],
    Cache extends Fn[] = []
    > =
    T extends []
    ? Cache
    : T extends [infer Lst]
    ? Lst extends Fn
    ? Allowed<[], [...Cache, Lst]> : never
    : T extends [infer Fst, ...infer Lst]
    ? Fst extends Fn
    ? Lst extends Fn[]
    ? Head<Lst> extends Fn
    ? Head<Parameters<Fst>> extends ReturnType<Head<Lst>>
    ? Allowed<Lst, [...Cache, Fst]>
    : never
    : never
    : never
    : never
    : never;

type LastParameterOf<T extends Fn[]> =
    Last<T> extends Fn
    ? Head<Parameters<Last<T>>>
    : never

type Return<T extends Fn[]> =
    Head<T> extends Fn
    ? ReturnType<Head<T>>
    : never


function compose<T extends Fn, Fns extends T[], Allow extends {
    0: [never],
    1: [LastParameterOf<Fns>]
}[Allowed<Fns> extends never ? 0 : 1]>
    (...args: [...Fns]): (...data: Allow) => Return<Fns>

function compose<
    T extends Fn,
    Fns extends T[], Allow extends unknown[]
>(...args: [...Fns]) {
    return (...data: Allow) =>
        args.reduceRight((acc, elem) => elem(acc), data)
}

const foo = (arg: 1 | 2) => [1, 2, 3]
const bar = (arg: string) => arg.length > 10 ? 1 : 2
const baz = (arg: number[]) => 'hello'

const check = compose(foo, bar, baz)([1, 2, 3]) // [number]
const check2 = compose(bar, foo)(1) // expected error

22