javascripttypescriptalgorithmtypescript-genericscurrying

How can I fix "Not all constituents of type are callable" for a recursive function?


So I have a function for currying other functions:

const curry = <TArg, TReturn>(fn: (...args: TArg[]) => TReturn) => {
  const curried = (...args: TArg[]) =>
    args.length < fn.length
      ? (...innerArgs: TArg[]) => curried(...args, ...innerArgs)
      : fn(...args);
  return curried;
};

const join = (a: number, b: number, c: number) => {
  return `${a}_${b}_${c}`;
};

const curriedJoin = curry(join);

curriedJoin(1)(2, 3); // Should return '1_2_3', but gives an error

Typescript doesn't let me call it more than once cause the first call might've returned a string. How would you fix it?


Solution

  • The only way this could work is if curry() returns a function whose output type depends significantly on the number of parameters in the input function and the number of arguments passed in. That means you will need it to be not only generic, but also use conditional types to express the difference. And you'll need the type of that function to be recursive, so we have to give it a name:

    interface Curried<A extends any[], R> {
      <AA extends Partial<A>>(...args: AA): 
        A extends [...{ [I in keyof AA]: any }, ...infer AR] ?
          [] extends AR ? R : Curried<AR, R> : never;
    }
    

    So a Curried<A, R> represents a curried version of a function whose parameter list is A and whose return type is R. You call it with some args of a generic rest parameter type AA, which must be assignable to a partial version of A (using the Partial<T> utility type). Then we need to figure out the rest of the parameter list, which is inferred to be AR (Note that perhaps A extends [...AA, ...infer AR] ? ⋯ would work, but if for some reason AA is narrower than the initial part of A, that would fail. So {[I in keyof AA]: any} just means "anything that's the same length as AA"). If AR is empty ([]) then the curried function returns R. Otherwise it returns Curried<AR, R>, so that the resulting function can be called also.

    The implementation could look like:

    const curry = <A extends any[], R>(fn: (...args: A) => R) => {
      const curried = (...args: any): any =>
        args.length < fn.length
          ? (...innerArgs: A[]) => curried(...args, ...innerArgs)
          : fn(...args);
      return curried as Curried<A, R>;
    };
    

    Note that I used the any type and a type assertion to convince the compiler that the implementation of curry is acceptable. The compiler can't really understand many higher-order generic type operations, or verify what function implementations might satisfy them.

    Let's test it out:

    const join = (a: number, b: number, c: number) => {
      return `${a}_${b}_${c}`;
    };
    
    const curriedJoin = curry(join);
    // const curriedJoin: Curried<[a: number, b: number, c: number], string>
    
    const first = curriedJoin(1);
    // const first: Curried<[b: number, c: number], string>
    
    const s = first(2, 3);
    // const s: string
    console.log(s); // "1_2_3"
    
    curriedJoin(1)(2, 3); // '1_2_3'
    

    Looks good, that all behaves as desired.


    But there's a caveat. Any code that depends on the length property of a function cannot be perfectly represented in TypeScript's type system. TypeScript takes the position that functions can safely be called with more arguments than the number of parameters, at least when it comes to assignability of callbacks. See the documentation and the FAQ. So you can have a function whose length does not agree with the number of parameters TypeScript knows about. Meaning you can possibly end up in this situation:

    function myFilter(predicate: (value: string, index: number, array: string[]) => unknown) {
      const strs = ["a", "bc", "def", "ghij"].filter(predicate);
      console.log(strs);
      const curried = curry(predicate);
      curried("a")(2);
    }
    

    myFilter() takes a callback of the type accepted by an array of strings' filter() method. This callback will be called with three arguments; that's how filter() works in JavaScript. But TypeScript lets you pass callbacks that take fewer arguments. So you can call this:

    myFilter(x => x.length < 3) // ["a", "bc"], followed by RUNTIME ERROR! 
    

    There's no compiler error anywhere, but you get a runtime error. The body of myFilter thinks the callback has a length of 3, but it really has a length of 1 at runtime. Oops.

    This might not happen in your actual code, but you should be aware of it.

    Playground link to code