Search code examples
typescripttypestypechecking

Generic class constructor passes typechecking but arguably shouldn't


Given:

class Rx<F extends unknown[], T> {
    constructor(
        _dependencies: { [k in keyof F]: Rx<any, F[k]> },
        _calculation: (...args: F) => T,
    ) { }
}

const fortyTwo: Rx<[], number> = new Rx([], () => 42);
const twentySeven: Rx<[], string> = new Rx([], () => "27");

So far so good.

Since twentySeven has type Rx<[], string>, I expect the following line to fail typechecking, but it passes:

const unexpected = new Rx([fortyTwo, twentySeven], (a: number, b: number) => a + b)

Variable unexpected gets assigned type Rx<[number, number], number>. I expected Rx<[number, string], number> and a type error.

  • What am I doing wrong?
  • How do I fix it?

Solution

  • TypeScript's type system is largely structural and not nominal. Two types that do not differ in terms of their object shape or structure, are considered not to differ in their type, either. Your Rx<F, T> class instance type is completely empty. The shape is the empty object type {}. So therefore all Rx<F, T> instance types are the same type, and just about anything is assignable to it. Unless you are intending for such assignability, you should not have empty classes whose types have nothing to do with your generic type parameters. See the TypeScript FAQ entries for "Why do these empty classes behave strangely?" and "Why is A<string> assignable to A<number> for interface A<T> { }?"?

    You should add structure to your class instance type. The easiest thing to do is just give the class properties corresponding to the constructor arguments (I mean, you would do that, right?):

    class Rx<F extends unknown[], T> {
      dependencies;
      calculation;
      constructor(
        _dependencies: { [K in keyof F]: Rx<any, F[K]> },
        _calculation: (...args: F) => T,
      ) {
        this.dependencies = _dependencies;
        this.calculation = _calculation;
      }
    }
    

    This gives your class a type like

    declare class Rx<F extends unknown[], T> {
      dependencies: { [K in keyof F]: Rx<any, F[K]>; };
      calculation: (...args: F) => T;
      constructor(_dependencies: {
        [K in keyof F]: Rx<any, F[K]>;
      }, _calculation: (...args: F) => T);
    }
    

    So now an instance of Rx<F, T> has a calcluation property of type (...args: F) => T which should be enough to begin to place restrictions you need:

    const fortyTwo: Rx<[], number> = new Rx([], () => 42);
    const twentySeven: Rx<[], string> = new Rx([], () => "27");
    
    const unexpected = new Rx(
      [fortyTwo, twentySeven], // error!
      //         ~~~~~~~~~~~
      // Type 'Rx<[], string>' is not assignable to type 'Rx<any, number>'.
      (a: number, b: number) => a + b
    )
    

    Playground link to code