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

2

5

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?

Pine answered 4/3, 2022 at 15:8 Comment(0)
G
5

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).

Gaea answered 5/3, 2022 at 4:7 Comment(2)
this is super useful to me, thanks for taking the time to describe it so cohesively!Pine
@Pine not at all! Pretty nice challenge to work around the, albeit by-design, limitation :)Gaea
M
1

It isn't happy because here you've said that it can only be onKeyDown:

      eventName: "onKeyDown";

It should probably be just string:

      eventName: string;

And if you want it to always start with on, you can even do that, too:

      eventName: `on${string}`;
Maryellen answered 4/3, 2022 at 15:21 Comment(3)
Thanks. I modified a little bit the description. The declare class is from third party library and I don't have access so I would prefer to create new interface extending from that and modifying it.. and I didn't figure out how.Pine
I think that means they don't want you to extend it; all its members are private, too.Maryellen
This is awesome, I never saw this kind of types before and I like it. Thanks @mochaccinoLenticular

© 2022 - 2024 — McMap. All rights reserved.