Typescript: variable function arguments depending on the preceding arguments

Viewed 1018

This question must have been asked before, I'm almost certain of it. Yet:

  1. I can't find any such question
  2. Typescript has made major leaps in the recent past, thus the existing answers might be outdated.

Something that I commonly use in my code is spread operator for function arguments allowing me to take in a variable length array of arguments. What I'm trying to do now is create TS type defenition for a function where arg1 type depends on arg2 type, and arg2 depends on arg3, and so on.

Very much like this in lodash https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/lodash/common/util.d.ts#L209

 flow<R2, R3, R4, R5, R6, R7>(f2: (a: ReturnType<T>) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, f7: (a: R6) => R7): Function<(...args: Parameters<T>) => R7>;

The lodash approach is obviously very limited and also maxes out (in this example) at 8 arguments. This is fine, and I can live with it, however, in 2021 is there a better, more recursive way? A way to do the same thing for a (theoretically) infinite number of arguments?

Please feel free to close the question and point me to an existing answer, if such exists and it's up to date.

2 Answers

With inference in conditional types, this is possible (albeit slightly ugly):

// convenience alias
type Func = (...args: any[]) => any;

// check arguments in reverse
// since typescript doesn't like deconstructing arrays into init/last
type CheckArgsRev<T extends Func[]> =
    // get first two elements
    T extends [infer H1, infer H2, ...infer R]
        // typescript loses typings on the inferred types
        // so this gains them back
        ? H1 extends Func ? H2 extends Func ? R extends Func[]
            // actual check
            // ensures parameters of next argument extends return type of previous
            // you can substitute this with whatever check you want to add
            // just know that H1 is the current argument type and H2 is the previous
            // also if you change this to work with non-functions then change Func to an appropriate type
            // like unknown to work with all types
            ? Parameters<H1> extends [ReturnType<H2>]
                // it was a match, recurse onto the tail
                ? [H1, ...CheckArgsRev<[H2, ...R]>]
                : never // invalid type, become never for error
            : never : never : never // should never happen
        // base case, 0 or 1 elements should always pass
        : T;

// reverse a tuple type
type Reverse<T extends unknown[]> =
    T extends [infer H, ...infer R]
        ? [...Reverse<R>, H]
        : [];

// check args not in reverse by reversing twice
type CheckArgs<T extends Func[]> = Reverse<CheckArgsRev<Reverse<T>>>;

// make sure the argument passes the check
function flow<T extends Func[]>(...args: T & CheckArgs<T>) {
    console.log(args);
}

// this is invalid (since number cannot flow into a string)
flow((x: string) => parseInt(x, 10), (x: string) => x + "1");
// this is valid (number flows into number)
flow((x: string) => parseInt(x, 10), (x: number) => x + 1);

Playground link

Aplet's answer is great! But it gives up the type information about the function array when you pass a wrong argument. An error that just says Argument of type '(x: string) => number' is not assignable to parameter of type 'never'. when the error is actually originating from (x: string) => string isn't super helpful.

Unfortunately there doesn't really seem to be a robust method of doing this, but I'd still like to share my 2 cents-

type AnyFunc = (a: any) => any;

// Given a tuple of functions - deduce how much of it is flowable
type FlowFrom<T extends readonly AnyFunc[]> = T extends [(a: infer R) => any, ...AnyFunc[]]
    ? // Infer the argument type of the first function and pass it to `FlowFrom$`
      FlowFrom$<R, T>
    : // T was empty array
      [];

// Actual function that deduces how much of a tuple is flowable, taking previous return type and remaining functions
type FlowFrom$<R, T extends readonly [(a: R) => any, ...AnyFunc[]]> = T extends [
    (a: R) => infer R$,
    ...infer Tail
]
    ? Tail extends [(a: R$) => any, ...AnyFunc[]]
        ? // Valid, continue
          [(a: R) => R$, ...FlowFrom$<R$, Tail>]
        : // Tail has either been exhausted or invalid function has been found
        Tail extends [(a: any) => any, ...AnyFunc[]]
        ? // Invalid function found, append a correct function type so it points out the specific error
          // But make the appended function optional, the flow is still valid before this point
          [(a: R) => R$, (a: R$) => any] | [(a: R) => R$]
        : // Tail exhausted
          [(a: R) => R$]
    : // T is empty, exhausted tuple (impossible - apparently)
      [];

FlowFrom deduces a "flowable" tuple type from a given tuple of functions. Essentially, it extracts all the functions in the tuple that will flow into each other, stopping whenever it encounters a function that is no longer flowable or when the tuple has exhausted.

// All the functions in the tuple type are flowable - the return type is the same
let valid: FlowFrom<[(x: string) => number, (x: number) => number]>;
//  ^ [(a: string) => number, (a: number) => number]

// First function won't flow into the second one - stop at first one
let invalid: FlowFrom<[(x: string) => number, (x: string) => number]>;
//  ^ [(a: string) => number, (a: number) => any] | [(a: string) => number]

This means, when you try to assign [(x: string) => parseInt(x, 10), (x: string) => x + '1'] to invalid, you get a nice error-

let invalid: FlowFrom<[(x: string) => number, (x: string) => number]> = [
    (x: string) => parseInt(x, 10),
    (x: string) => x + '1',
];
//  ^ Type 'number' is not assignable to type 'string'

But, you can still assign the stuff before that breaking point-

let reducedButCorrect: FlowFrom<[(x: string) => number, (x: string) => number]> = [
    (x: string) => parseInt(x, 10),
];

(in case you don't want this behavior, as in - being able to still assign everything before the breaking point when a breaking point is present, remove the union type from FlowFrom$, [(a: R) => R$, (a: R$) => any] | [(a: R) => R$] -> [(a: R) => R$, (a: R$) => any])

You can now use it like-

declare function flow<T extends readonly AnyFunc[]>(...args: T & FlowFrom<T>): unknown;

// Valid
flow(
    (x: string) => parseInt(x, 10),
    (x: number) => Boolean(x),
    (x: boolean) => Number(x)
);

// Invalid
flow(
    (x: string) => parseInt(x, 10),
    (x: number) => Boolean(x),
    (x: string) => Number(x)
);
//  ^ Type 'boolean' is not assignable to type 'string'

Try all of this out on playground

Just wanted to share my two cents.

Related