Arbitrary amount of generic arguments

Viewed 197

Consider an arbitrary amount of functions, each taking an argument, and returning a value (dont worry about what they do, not the point):

function toNumber(input: string): number {
  return parseInt(input)
}

function toBoolean(input: number): boolean {
  return input > 0
}

function toString(input: boolean): string {
  return input.toString()
}

These functions could be chained (probably theres a better term in fp) like this, since each takes the previous one's return type as argument:

type Fn<I, O> = (input: I) => O

function chain(...fns: Fn<any, any>[]): Fn<any, any> {
  return (input: any) => {
    let result = input
    for (const fn of fns) {
      result = fn(result)
    }
    return result
  }
}

const chained = chain(toNumber, toBoolean, toString)

const result = chained('5') // returns "true"

Is there a way in typescript to make the chain function type safe? Since we have an arbitrary amount of functions there has to be an arbitrary amount of generic arguments for this.

I could do something like this (fixed number of overloads):

declare function chain<S, T1>(fn: Fn<S, T1>): Fn<S, T1>
declare function chain<S, T1, T2>(fn1: Fn<S, T1>, fn2: Fn<T1, T2>): Fn<S, T2>
declare function chain<S, T1, T2, T3>(fn1: Fn<S, T1>, fn2: Fn<T1, T2>, fn3: Fn<T2, T3>): Fn<S, T3>

But is there a better way?

1 Answers

I'm not sure that I would call this a better way, but is is possible to encode this rule into the type system. However, most observers would probably have a hard time understanding what this does without good documentation around it.

The approach below uses a generic argument that extends ReadonlyArray<Fn<any, any>> in order to achieve an "arbitrary amount of generic arguments".


First, a couple of utility types to help out with arrays and functions:

type Fn<I, O> = (input: I) => O;

type Input<F> = F extends Fn<infer U, any> ? U : never;

// or you can use ReturnType<T>, which is provided by typescript
type Output<F> = F extends Fn<any, infer U> ? U : never;

// first elem of tuple type
type Head<T> = T extends [infer U, ...unknown[]] ? U : never;

// all but first elem of tuple type
type Tail<T> = T extends [unknown, ...(infer U)] ? U : never;

// last element of array, only works on typescript 4.2+
type Last<T> = T extends [...unknown[], infer U] ? U : never;

Next, a type to check if two functions can be composed

// `true` if T can be composed with U, `false` if not, and `never` if the types are wrong
type CanCompose<T, U> = T extends Fn<any, infer Output> 
    ? (
        U extends Fn<infer Input, any>
            ? (Input extends Output ? true : false) 
            : never
    ) 
    : never;

A type to check if a whole list of functions can be composed with each other

// Evaluates to a tuple of booleans. If any element in the tuple is false, you cannot compose the whole array of T.
type IsComposable<T> = T extends Fn<any, any>[] 
    ? CanCompose<Head<T>, Head<Tail<T>>> extends true 
        ? (
            Tail<T> extends never[]
                ? [true] 
                : [true, ...(IsComposable<Tail<T>>)]
        ) 
        : [false]
    : [false];

Finally, the type of the rest parameter and return value for the chain function

type Composable<T> = IsComposable<T> extends true[] ? T : never[];
type ComposedFunction<T> = IsComposable<T> extends true[] 
    ? Fn<Input<Head<T>>, Output<Last<T>>>
    : Fn<never, never>;

The chain function now gets a more exciting signature, and some type casts in the body. I'm not sure how to get rid of the type casting, unfortunately.

function chain<T extends ReadonlyArray<Fn<any, any>>>(...fns: Composable<T>): ComposedFunction<T> {
    return ((x: Input<Head<T>>) => {
        let result = x;
        for (const fn of fns) {
            result = fn(result);
        }
        return result;
    }) as ComposedFunction<T>
}

One downside of this approach is that if user code violates the types, they get a cryptic compile error that the type of the first argument to chain is not assignable to never.

Link to TS playground with all of this stuff in it

Related