Search code examples
typescriptparameterstuples

How to infer type of function argument in "proxy" function


I am attempting to implement a "proxy" method, which is able to call other methods of the class dynamically. All works well until I start playing around with types of other methods arguments. Following example gives A spread argument must either have a tuple type or be passed to a rest parameter.

What am I doing wrong? According to docs, Parameters utility function returns tuple :(

class testClass {
  public methodA(a: number): void {
  }

  public methodB(b: string): void {
  }

  public callRandom(methodName: 'methodA', options: [number]): void;
  public callRandom(methodName: 'methodB', options: [string]): void;
  public callRandom(methodName: 'methodA' | 'methodB', options: [number] | [string]) {
      type optionsType = Parameters<this[typeof methodName]>; 
      this[methodName](...options as optionsType);
  }
}

Playground


Solution

  • You've run into a known bug / limitation of TypeScript, described at microsoft/TypeScript#36874 and microsoft/TypeScript#42508 (and issues linked therein). If you're trying to spread something into a function call TypeScript currently only seems to allow that if its type is a single tuple. Unions of tuples, or generics that are constrained to tuples, or conditional types that evaluate to tuples are not supported.

    If you want to just push ahead and prevent the error, you can use a type assertion:

      public callRandom(methodName: 'methodA' | 'methodB', options: [number] | [string]) {
        type optionsType = Parameters<this[typeof methodName]>;
        this[methodName](...options as [never]);
      }
    

    So, [never] is a single tuple type, so it's seen as spreadable. But why never? That's because the compiler has no idea whether methodName is "methodA" or "methodB". It's a union of the two. So this[methodName] will be a union of functions. And a union of functions can only be called safely with an intersection of its parameter types. See the TS3.3 release notes for a description of that functionality. Since methodA wants a number, and methodB wants a string, you can only safely pass something which is both a number and a string, so it doesn't matter which method you're calling. Uh, but there is no such thing. number & string is reduced to the impossible never type. Thus the only thing the compiler will let you pass as options is [never].

    You might be thinking that methodName and options are correlated to each other in such a way that it's always appropriate to call this[methodName](...options), but that correlation is not something the compiler sees. The implementation of your function has methodName and options as two independent union types, so as far as the compiler knows inside that implementation, it's quite possible for methodName to be "methodA" while options is [string]. TypeScript really doesn't have direct support for correlated unions, as described in microsoft/TypeScript#30581.


    If you want the compiler to actually "understand" that this[methodName](...options) is always appropriate, then you need to refactor your code as described in microsoft/TypeScript#47109. The idea is to move away from unions (and overloads in your case), and toward generics. You represent your operations in terms of a "basic" key-value type, and in terms of generic indexes into that type, and in terms of generic indexes into mapped types over that type.

    For your example it might look like

    interface MethodParam {
      methodA: [number],
      methodB: [string]
    }
    
    class TestClass {
      public methodA(a: number): void {
      }
    
      public methodB(b: string): void {
      }
    
      public callRandom<K extends keyof MethodParam>(
        methodName: K, 
        options: MethodParam[K]
      ) {
        const t: { [P in keyof MethodParam]: (...args: MethodParam[P]) => void } = this;
        t[methodName](...options);
      }
    }
    

    The basic type is MethodParam, and we assign this to a variable t of the mapped type { [P in keyof MethodParam]: (...args: MethodParam[P]) => void }. The compiler sees that assignment as acceptable (because it goes through each property of MethodParam and can verify that TestClass has an appropriately shaped method at that property). And methodName is a key of MethodParam of generic type K. And options is a generic index into MethodParam, MethodParam[K].

    Now t[methodName] is a generic index into that mapped type, and can be seen as the single function type (...args: MethodParam[K]) => void. Since options is of type MethodParam[K], then t[methodName](...options) is acceptable. No unions of functions are involved, and no intersections of parameters are required.

    This refactoring might not be worth it for your use case. You might care more about expedience ("stop complaining, compiler, and let me get on with my day") than protection ("I need you to understand what I'm doing, compiler, so that if I make a mistake you'll catch it"). If so, then a type assertion is the way to go.

    Playground link to code