Search code examples
typescripttypescript-genericstype-inference

How to correctly give restrictions to typescript generics


I am creating this class (playground link):

export class CascadeStrategies<
  T extends Record<any, (...args: any[]) => unknown>
> {
  private strategies: T = {} as T;

  constructor(strategyMap: T) {
    this.registerStrategies(strategyMap);
  }

  private registerStrategies(strategyMap: T) {
    this.strategies = strategyMap;
  }

  use(
    strategies: (keyof T)[],
    ...args: Parameters<T[keyof T]>
  ): ReturnType<T[keyof T]> {
    return this.strategies[strategies[0]](...args);
  }
}

The expected use of this class should be

const myMap = {
  test: (arg1: number, arg2: string) => arg1,
  otherTest: (arg1: number, arg2: string) => arg2,
  thirdTest: (arg1: number, arg2: string) => null
}
const cascadeStrats = new CascadeStrategies(myMap);
const shouldBeNumber = cascadeStrats.use(["test"], 0, "");
const shouldBeString = cascadeStrats.use(["otherTest"], 0, "");
const shouldBeNull = cascadeStrats.use(["thirdTest"], 0, "");

I want T to be an object whose entries are functions that can accept the same set of parameters and returns a string, so I am using T extends Record<any, (...args: unknown[]) => string.
With this typing, this.strategies[strategies[0]](...args) has type unknown which is incompatible with the expected ReturnType<T[keyof T]>.

If I change the type of strategies from T to Record<any, any>, this.strategies[strategies[0]](...args) will have the correct type and is correctly inferenced when used. Even though strategies is just an internal variable and does not affect DX when using the class, I was wondering what I am missing here to achieve the desired result:

  • Correct inference while the user defined strategyMap (i.e. object whose entries are functions that accept the same set of parameters and that return string).
  • strategies has not Record<any, any> type.
  • When the user use cascadeStrats.use he gets correct inferences in the arguments of the function and the returned type.

Solution

  • I think the most straightforward way to express this is to split your generic type parameter apart into two. You can have A, the parameter list type common to all the strategies, and T, the mapping from strategy keys to the return type of the corresponding strategy. Given those types, then strategies would be of type

    type Strategies<A extends any[], T> =
      { [K in keyof T]: (...args: A) => T[K] }
    

    which is a mapped type converting each member of T into a function that returns that member.

    Here's how I'd chance CascadeStrategies:

    class CascadeStrategies<A extends any[], T extends object> {
      private strategies!: Strategies<A, T>
    
      constructor(strategyMap:
        Record<string, (...args: A) => any> &
        Strategies<A, T>
      ) {
        this.registerStrategies(strategyMap);
      }
    
      private registerStrategies(strategyMap: Strategies<A, T>) {
        this.strategies = strategyMap;
      }
    
      use<K extends keyof T>(
        strategies: K[],
        ...args: A
      ) {
        return this.strategies[strategies[0]](...args); // okay
      }
    }
    

    That compiles without error. The important part here is what's going on inside use(). Now that function is generic in K, a key of T. The return type of use() is inferred T[K], as desired.

    Note that in order for this to work, we need TypeScript to infer both A and T when you write new CascadeStrategies(myMap). Inference can be tricky. My approach was to make the constructor parameter be of type Record<string, (...args: A) => any> & Strategies<A, T>. That's an intersection, where each piece helps infer a different type argument. The Record<string, (...args: A) => any> type allows A to be inferred, since it can happen before TypeScript knows anything about T. And then Strategies<A, T> allows T to be inferred from the return types of the methods. It's always safe to widen an intersection X & Y to one of its members Y, so we can dispose of the complicated intersection and just treat strategyMap as type Strategies<A, T>.


    Let's test it out:

    const myMap = {
      test: (arg1: number, arg2: string) => arg1,
      otherTest: (arg1: number, arg2: string) => arg2,
      thirdTest: (arg1: number, arg2: string) => null
    }
    const cascadeStrats = new CascadeStrategies(myMap);
    /*    ^? const cascadeStrats: CascadeStrategies<
        [arg1: number, arg2: string], 
        { test: number; otherTest: string; thirdTest: null; }
        >
    */
    const shouldBeNumber = cascadeStrats.use(["test"], 0, "");
    //    ^? const shouldBeNumber: number
    const shouldBeString = cascadeStrats.use(["otherTest"], 0, "");
    //    ^? const shouldBeString: string
    const shouldBeNull = cascadeStrats.use(["thirdTest"], 0, "");
    //    ^? const shouldBeNull: null
    const shouldBeStringOrNumber = cascadeStrats.use(["test", "otherTest"], 0, "")
    //    ^? const shouldBeStringOrNumber: string | number
    
    cascadeStrats.use(["test"], "oops", "abc"); // error!
    // -----------------------> ~~~~~~
    // Argument of type 'string' is not assignable to parameter of type 'number'.
    

    Looks good. T is inferred properly, so that shouldBeXXX are all of the expected types, and A is also inferred properly, so that the compiler notices if you pass in the wrong parameter type (as shown in the last line above).

    Playground link to code