Search code examples
typescriptinheritancetypescript-class

How to override a property of a base class with an incompatible type?


I have this declared class in some third party library that I can't modify:

export declare class KeyboardSensor implements SensorInstance {
  private props;
  constructor(props: KeyboardSensorProps);
  private attach;
  private handleStart;
  private detach;
  static activators: {
      eventName: "onKeyDown";
      handler: (event: React.KeyboardEvent, { keyboardCodes, onActivation, }: KeyboardSensorOptions) => boolean;
  }[];
}

And I want to customize it in this way with new class extending from that declaration:

export class CustomKeyboardSensor extends KeyboardSensor {
  public static activators = [
    {
      eventName: "onKeyDown" as const,
      moreCode: "...",
    },
    {
      eventName: "onKeyUp" as const,
      moreCode: "...",
    },
  ];
}

I am obviously getting an error that the type property is incompatible:

Types of property 'eventName' are incompatible.

How can I define new interface/type for the declare class where I can modify the eventName type?


Solution

  • Class inheritance in TypeScript does not allow derived classes to be broader than base classes. As per the Handbook:

    TypeScript enforces that a derived class is always a subtype of its base class.

    This means that members of the class that extends the base class that you override are covariant (as derived class is always a subclass of its base or, put simply, is more specific). Consider the following - the override works because "A" is a subtype of a broader union "A" | "B":

    class A {
        static b : Array<{ c: "A" | "B" }> = []
    }
    
    class B extends A {
        static b : Array<{ c: "A" }> = [] // OK
    }
    

    However, the opposite results in an assignability error because the overridden members are not contravariant:

    class C {
        static b : Array<{ c: "C" }> = []
    }
    
    class D extends C {
        static b : Array<{ c: "C" | "D" }> = [] // Type '"D"' is not assignable to type '"C"'
    }
    

    The latter example is semantically equivalent to your case: eventName is declared to be a string literal type onKeyDown, meaning any and all extending classes are not allowed broaden it, hence the error.


    Your options are limited, however, there is a hacky way to go around that. Suppose you have the following base class E:

    class E {
        constructor(public e : string) {}
        static b : Array<{ c: "E" }> = []
        static s : number = 42;
    }
    

    First, let's declare the derived class and name it somehow, let it be FB:

    class FB extends E {
        constructor(public f: number) {
            super(f.toString());
        }
    }
    

    Pretty simple so far, right? Here comes the juicy part:

    const F: Omit<typeof FB,"b"> & { 
        new (...args:ConstructorParameters<typeof FB>): InstanceType<typeof FB> 
        b: Array<{ c: "E" | "D" }>
    } = FB;
    

    There is a lot to unpack. By assigning the declared derived class to a variable, we create a class expression const F = FB; which enables the static part of the class to be typed via explicit typing of the F variable. Now for the type itself0:

    • Omit<typeof FB, "b"> ensures the compiler knows the static side of FB (and, consequently, the base class E) is present except for the member b which we will be redefining later.
    • new (...args:ConstructorParameters<typeof FB>): InstanceType<typeof FB> reminds the compiler that F is a constructor, whereas args:ConstructorParameters and InstanceType utilities free us to change the base class without the need to update the derived constructor type.
    • b: ... readds the omitted b member to the static side of the derived class while broadening it (note that as class inheritance is not involved, there is no error).

    All the above fixes the b member during compile-time, but we still need to make sure the static member is available at runtime with something like this (see MDN on getOwnPropertyDescriptor / defineProperty for details):

    const descr = Object.getOwnPropertyDescriptor(E, "b")!;
    Object.defineProperty(F, "b", { 
        ...descr, 
        value: [...descr.value, { c: "D" }] 
    });
    

    Finally, let's check if everything works as expected:

    console.log(
        new F(42), // FB
        new F(42).e, // string
        F.b, // { c: "E" | "D"; }[]
        F.s // number
    );
    
    // at runtime:
    // FB: {
    //   "e": "42",
    //   "f": 42
    // },
    // "42",  
    // [{ "c": "D" }],  
    // 42 
    

    Playground with examples above | applied to your case


    0 Note that we often have to use the typeof FB type query — if we haven't declared the class earlier and opted to shortcut to const F: { ... } = class ..., we would not be able to refer to the class itself when explicitly typing the variable (if we tried, the compiler would complain of a circular reference).