Repository navigation
Allow Generic Child Classes to Satisfy Only a Subset of Parent Constructor OverloadsΒ #52585
Description
Suggestion
π Search Terms
constructorAllow Generic Child Classes to Satisfy Only a Subset of Parent Constructor Overloadsoverloadsextend constructor
β Viability Checklist
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This feature would agree with the rest of TypeScript's Design Goals.
β Suggestion
Currently, the following does not compile in TypeScript 4.9
interface ParentClassConstructor {
new <T extends number>(arg: T): ParentClass;
new <T extends string>(arg: T): ParentClass;
}
interface ParentClass {
method(): void;
}
const ParentClass: ParentClassConstructor = class<T extends number | string> {
constructor(arg: T) {
// Do something with generic argument
}
method(): void {
// Do anything
}
};
class ChildClass<T extends number> extends ParentClass<T> {
constructor(number: T) {
// Do something with number argument
}
}Instead, I get the following error when trying to work with the ChildClass:
Type 'T' does not satisfy the constraint 'string'.
Type 'number' is not assignable to type 'string'.
In fact, changing the T type parameter for ChildClass to T extends number | string also fails. It seems that if the generic type parameter of the child class does not satisfy each of the possible generic constructors in the parent constructor interface, then the compiler will fail.
My suggestion/feature request is that TypeScript would be updated to permit the use of child classes whose generic type parameters satisfy a subset (at least 1) of the generic overloads of the parent class constructor. This would allow the code above to successfully compile.
If it's worth noting, the following is already legal currently:
interface ParentClassConstructor {
new (arg: number): ParentClass;
new (arg: string): ParentClass;
}
interface ParentClass {
method(): void;
}
const ParentClass: ParentClassConstructor = class {
constructor(arg: number | string) {
// Do something with argument
}
method(): void {
// Do anything
}
};
interface ChildClassConstructor {
new (arg: number): ChildClass;
}
interface ChildClass {
method(): void;
}
const ChildClass: ChildClassConstructor = class extends ParentClass {
constructor(number: number) {
// Do something with argument
super(number) // Only call `number`-based constructor of parent
}
}So I'm hoping this isn't an unreasonable request.
π» Use Cases / Examples
A feature like this would be incredibly helpful when it comes to maintenance of libraries using classes that require (or are improved by) well-defined generics. The best libraries give end-developers tools that give them the features they need without restricting their options, but these libraries often provide baked in solutions for common use cases. When these libraries depend on the concept of classes, there's often a base class -- with the baked-in solutions extending the base class for ease. This guarantees that some devs can use the baked in solution if it's sufficient, while others can reach for the more low-level option if needed.
I'm working on a frontend tool that follows the idea of an "Observer" (e.g., IntersectionObserver, MutationObserver, etc.). But it's written with JS and interacts with the DOM. This class (let's call it BaseObserver) has multiple constructor overloads to support different kinds of options for setting up the observer. And these constructors need to be generic to make it clear what combinations of constructor argument types work and what combinations don't work. (Unions alone won't make it clear what combinations are or are not legal.) In some cases, generic constructors are also needed for DOM-related type inference.
There are common use cases that this observer will be needed for. And rather than require the end-dev to come up with a solution on their own, I'm seeking to export other reliable, tested solutions as well. In the end, I'd like to have something like this:
/* -------------------- Base Observer -------------------- */
interface BaseObserverConstructor {
new <T extends number>(arg: T): BaseObserver;
new <T extends string>(arg: T): BaseObserver;
}
interface BaseObserver {
observe(element: HTMLElement): void;
unobserve(element: HTMLElement): void;
disconnect(): void;
}
export const BaseObserver: BaseObserverConstructor = class<T extends number | string> {
constructor(arg: T) {
// Do something with argument
}
observe(element: HTMLElement): void {
// Observe the element
}
unobserve(element: HTMLElement): void {
// Undo the observation
}
disconnect(): void {
// `unobserve` everything
}
};
/* -------------------- Specific Observer -------------------- */
interface SpecificObserverConstructor {
new <T extends number>(arg: T): SpecificObserver;
}
interface SpecificObserver extends BaseObserver {
additionalNeededMethods(): void;
}
export const SpecificObserver: SpecificObserverConstructor = class<T extends number> extends BaseObserver<T> {
constructor(arg: T) {
// Do something with the argument
super(arg); // ONLY call the `number`-based overload of `BaseObserver`
}
observe(element: HTMLElement): void {
// Do any additional setup required
super.observe(element);
}
unobserve(element: HTMLElement): void {
// Do any additional teardown required
super.unobserve(element);
}
additionalNeededMethods(): void {
// Anything extra needed to guarantee this specific class works as needed
}
};Although I don't know ahead of time what the developer will do with BaseObserver, I do know what the exact shape and purpose of SpecificObserver will need to be. So I can make SpecificObserver conform to a subset of the generic types supported by BaseObserver and provide that to the end-devs. But to do that, I need the compiler's permission. π
Activity
RyanCavanaugh commentedon Feb 3, 2023
The example doesn't show motivation for why
ParentClassConstructorshould have generic call signatures, instead of being generic itself, or simply not being generic at all. Expanding on that would be helpful.ITenthusiasm commentedon Feb 3, 2023
For sure! As a note, the repository of the aforementioned tool is private, and I've been trying to keep the code examples small; but I'll do my best to expand more. (If it's insufficient, please let me know.)
Continuing with the
BaseObserver(which generally represents the project/problem actively being worked on), one of the primary ways that it operates is by attaching event listeners to theDocument. More specifically, the user will indicate what type of event they want to listen for, and what to do when that event is emitted (through a supplied listener callback). I guess in a sense, you could call the observer a sophisticated, carefully designed wrapper arounddocument.addEventListener/document.removeEventListenerfor accomplishing a specific purpose. Note that our tool also applies some "enhancements" to the provided listener callback(s).We want the users of our package to have all of the information that they may need while listening for these events. This necessitates a generic because there are various types of events (
Event,InputEvent,MouseEvent, etc.). So we end up with something like this:However, the end user may want to run a given listener not just for one type of event, but for multiple types of events. This, yet again, requires a generic.
There is another use case for another generic overload as well. But if we stop here, we already see that we're in a situation where we have 2 different type guards. Per the earlier comment, this will be impossible to extend (with TypeScript's current capabilities). Inside the class itself, this isn't really a big deal, as the generic is really only used in the constructor:
(For the same reason, making the class a generic won't do any good. It's the constructors that must be generic.)
But this is a big deal when: 1) We try to extend our own class internally to provide baked-in solutions, 2) An end-developer tries to extend our low-level class, or 3) An end developer wants accurate types, guards, and IntelliSense on the arguments which they pass to the constructor. (As mentioned earlier, a mere union of types here is insufficient.) Currently, the first 2 options are impossible because TypeScript does not support this kind of extension.
The third reason is the most important: We want to provide a good developer experience to our end users. And for that reason, generic constructors cannot be removed. (Just as it would be very bad DX if
document.addEventListenercouldn't tell you that your listener is passed aMouseEventwhen listening for"mouseover", so it is with ourBaseObserverand its extensions. And there's more to our observers than just callingdocument.addEventListener.)An alternative to this solution is for
BaseObserverto be reduced from multiple constructors to 1 (the first constructor), and to have the end-developer create as many instances ofBaseObserveras are necessary to accomplish what would have been possible with a proper, generic, overloaded constructor. This would resolve the type errors. However, this developer experience is much worse. There are some things that would make more sense to group together under one observer. And it's technically more efficient/performant (assuming the class is designed well) to just instantiate one class rather than create multiple classes that basically do the same thing barring some minor difference like event types.Is that helpful? Unhelpful? (There is more that I could expand on, but I'm hoping this is sufficient. Just let me know. ππΎ)
ITenthusiasm commentedon Feb 21, 2023
Edit: Sorry, I didn't realize that TS already validates
ConstructorInterfaceagainstCLASS_EXPRESSION.constructor(). In that case, I guess the idea described below wouldn't be needed after all. But it could still be another potential option?I've been thinking about this a bit more. I'm wondering if a potential solution to this problem would be a "catch-all constructor".
Currently, when we're overloading functions, the final function definition actually doesn't get exposed to the user at all. It's simply a "catch-all" that's intended to help the developer carefully write the correct implementation of the function for all potential use cases.
The
constructorhas this same behavior if it's defined within theclassbody. But if the constructor overloads are all defined as part of aninterface, then there's no "catch-all" opportunity for the developer to take advantage of and/or work with -- despite the fact that the developer will effectively need to account for a catch-all within the actual body of their constructor. If we could define a catch-all constructor type that didn't get exposed to users, then maybe instead of type checking every possible constructor in the interface, TS could just check the "catch-all constructor" during the use ofextends. (As a bonus, this could also potentially create a way for TS to type guard class expressions relying on a constructor interface, because it could throw errors if the constructor in the class body was incompatible with the catch-all constructor in the interface.)Does that make sense?
RyanCavanaugh commentedon Feb 23, 2023
This isn't really making sense to me, to be honest. Trying to paste all your examples into a coherent snippet, I either get no errors, or correct errors (bona fide constraint violations). A simpler example that still demonstrates the thing you'd like to do (but aren't allowed to) would be really useful.
ITenthusiasm commentedon Feb 27, 2023
Yeah, sorry. In the end I realized that the last comment I gave wouldn't really work out as an idea for a solution. Maybe I should minimize it?
Or are you saying that the previous comments (the ones before this guy) that I gave earlier also don't make sense? And that you'd want those in a single snippet?
RyanCavanaugh commentedon Feb 27, 2023
A single self-contained snippet showing what you're trying to do / why it's not working is always ideal, right.
ITenthusiasm commentedon Feb 28, 2023
Gotcha. Thanks for your patience. Based on the justification laid out earlier, I'd like to do something like this:
Similar to the opening comment, I get the following error (line 44 in the TS Playground) while trying to use
extends:It's admittedly a little odd that TS prevents me from extending
BaseObserverhere becauseTsatisfies at least one of theBaseObserver's constructor overloads. I would think that the natural behavior would be for TS to allow the extension since one overload is satisfied. But instead, TypeScript is validatingTacross all constructor overloads.Note that I also cannot replace line 44 (in the TS Playground) with
either because
EventTypedoes not satisfy the second constructor overload andReadonlyArray<EventType>does not satisfy the first constructor overload. So it's a catch 22. There's no (clean) workaround.It would be great if TS would permit me to perform
extendswhen I can match at least a subset of the provided constructors. It would be even better if TS would allow me to use the same TypeGuardTonSpecificObserver(line 44) as I'm using onBaseObserver(line 17).RyanCavanaugh commentedon Feb 28, 2023
The class rule is pretty strict. In the general case, it's not sufficient to show that you only match one overload or the other, since an actual invocation could end up matching either overload. If you have two overloads,
f(x: T)andf(x: U)and only match on the second overload forU, an actual call could bef(T & U)and match theToverload instead. In this specific example that's not possible, but it's not really easy to see why.Anyway, you can rewrite this to a working form that I think captures most of the intent here:
ITenthusiasm commentedon Mar 2, 2023
Wow, that's some pretty intense magic right there. π₯ Thanks for the snippet! Unfortunately, there's one more overload that maps the
ReadonlyArrayoftypesto aReadonlyArrayoflisteners(whose function arguments correspond to the provided types, one-by-one). Is that possible with this approach? I tried to extend what you showed me but couldn't get anything working.That is indeed a dilemma. π€ However, TS doesn't account for this when it comes to non-generic function overloads, does it? Consider this scenario:
All 3 invocations of this function are allowed by TypeScript. However, unexpected results may come from the third invocation. My understanding was that to an extent, TS left it up to developers to carefully craft their implementation of their overloads to prevent such problems. Devs are also encouraged to list more specific overloads first so that the correct, most simple overload is always displayed to the end user.
Is this considered a bug (whether in TS or in the implementation)? If it isn't a bug, would it make sense to allow similar things for generics?