-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Constructor generic types and this
parameter (TS1092, TS2681)
#40451
Comments
I really appreciate the super-clear use case examples here |
cross-linking to #5449 |
I also think it'd be super useful to have generics on constructors. Any updates on this issue? |
I find this to be a useful feature that I need. |
Might I suggest an alternative "Alternative to get this working now"? Instead of using never on the constructor, you could just make it a private constructor |
Pinging @sandersn from #55919. (I couldn't tell if you were asking me to provide my thoughts here or
This is actually my exact use case. The Use CaseI'm working on a utility that will allow developers to run sophisticated logic with event listeners. Let's call it the class ElementObserver {
observe(element: HTMLElement): void {
// Register Listeners
}
unobserve(element: HTMLElement): void {
// Unregister Listeners
}
disconnect(): void {
// `unobserve` everything
}
}; I want the class to be able to support different kinds of setup. In one situation, the user might just want to run sophisticated logic whenever a specific event is emitted. In another situation, users might want to run this logic when any one of multiple events are emitted. Separately, they might want to run one listener for one event and another listener for another event. To manage these scenarios, the constructor needs a declaration like this type EventType = keyof DocumentEventMap;
class ElementObserver {
/** `listener` will be called when `T` is emitted (during observation) */
constructor<T extends EventType>(type: T, listener: (event: DocumentEventMap[T]) => unknown);
/** `listener` will be called when any one of the events listed in `T` is emitted (during observation) */
constructor<T extends ReadonlyArray<EventType>>(types: T, listener: (event: DocumentEventMap[T[number]]) => unknown);
/** `listener[0]` will be called when `T[0]` is emitted, and so on (during observation) */
constructor<T extends ReadonlyArray<EventType>>(types: T, listeners: TYPE_CONVERTING_T_TO_ARRAY_OF_LISTENERS);
// ...
} If I implement the constructor properly, the actual interface exposed to my users ( Instead, the constructor needs to be generic. This is because the type of Update: This utility has now been released (together with some other framework integrations), so we'll see how it gets received. If you want clearer insight into what exactly I'm trying to do with generic constructors, see the Limitations of the Interface + Class Expression ApproachI know that generic constructors can be "simulated" by using an 1) Separation of Documentation and ImplementationThe nice thing about class definitions is that you can type a method and document it in the same area. This makes it easy for developers to know what a method (or its overloads) is intended to do. class MyClass {
/**
* Does something with strings
* @param arg A string
*/
method(arg: string): void;
/**
* Does something with numbers
* @param arg A number
*/
method(arg: number): void;
method(arg: string | number): void {
/* ... */
}
} When the This isn't the end of the world, but it does make maintenance more difficult because the developer has to look somewhere else (besides the class definition) to understand what all the methods are intended to do. 2) Unsafe Types for OverloadsThe following is allowed by TypeScript interface MyInterface {
method(arg: string): void;
method(arg: number): void;
}
class MyClass implements MyInterface {
method(arg: string | number): void {
/* ... */
}
} This is technically valid when it comes to the pure Classes that get to define their own types and their own documentation do not have this problem. But if classes with generic constructors are forbidden, then the risk that I just described remains for developers relying on the 3) Duplication of Type InformationIn order to avoid the aforementioned problem, you must rewrite the overloads that were already defined on the interface. class MyClass implements MyInterface {
method(arg: string): void;
method(arg: number): void;
method(arg: string | number): void {
/* ... */
}
} Besides being inconvenient, it still leaves the door open for some unexpected behavior. (You never know what kind of accidents can happen when a class's documentation and method declarations are separated from their "true definitions" in the class expression.) class MyClass implements MyInterface {
method(arg: string): void;
method(arg: number): void;
method(arg: boolean): void;
method(arg: string | number | boolean): void {
/* ... */
}
} Addressing Implementation ConcernsOne of the concerns mentioned by @RyanCavanaugh was that it would be difficult for consumers to supply type parameters for generic constructors. However, since generic constructors are a feature that would be useful for different scenarios (such as the one described above), I think it would be safe enough to "kick the can of prohibition" down the road. That is, instead of forbidding the use of generic constructors, simply forbid consumers from providing type arguments for generic constructors. (Consumers should still be able to provide type arguments for the class's generic types.) It still leaves some dissonance between how functions/methods work and how constructors work (in terms of type arguments), but I think the end result will be better (since it opens more opportunities to developers without taking anything away). My assumption is that this should be safe to do. Because a generic constructor type, I'll provide some additional justification. We'll be using the following as a point of reference: class MyClass<T> {
constructor<U>(x: T, y: U) { /* ... */ }
} Only the Constructor Needs to Know the Generic TypeIn situations where the constructor is truly generic and not the class, then only the constructor needs to be aware of the type, not the class. This still holds even when the physical class itself is generic. Think of a method belonging to a class. We know that it can define its own type parameters: class MyClass<T> {
constructor<U>(x: T, y: U) { }
method<S>(arg: S): void { /* ... */ }
} The type parameter If this logic holds, then it's also true that consumers have no need to specify User-Provided Type Arguments Are Only Relevant When the Author Can't Predict the OutcomeI know that functions/methods allow type parameters to be specified when they're called. But in practice, I've only seen this to be useful in situations where the author can't possibly know what the consumer needs. For example, But we aren't dealing with the wild unpredictability of the DOM. We're talking about classes with explicitly-defined behavior. More specifically, we're talking about class constructors with explicitly-defined behavior. It's hard for me to imagine a scenario where the author of a class constructor wouldn't be able to predict its outcome. It's even harder for me to imagine a scenario where a consumer would need to specify the type parameter, because realistically they wouldn't be able to use it after the class is instantiated. Extra NotesBecause a literal, ES6 constructor is distinguished from a regular function that behaves like a constructor in TypeScript (and rightly so), such regular functions do not need to share these restrictions. They can keep working as normal. Separately, if there are any situations where an author really wants a generic constructor to impact a generic class's interface, they should probably just create two separate generics: |
Because TypeScript does not yet support generic constructors for JSDocs, this change is necessary in order to guarantee a decent user experience for the users of `@form-observer/core`. See: - microsoft/TypeScript#55919 - microsoft/TypeScript#40451
To add another use case, I'm putting together type definitions for a third-party library which uses bound constructors to determine return type. For example: import { type DefaultClass, froble } from "some-lib";
const defaultClassResult: DefaultClass = froble(...args);
class AnotherClass {/* … */}
const anotherFroble = froble.bind(AnotherClass);
const anotherClassResult: AnotherClass = anotherFroble(...args); (Note that the bound class doesn't even need to extend the default class; it just needs to be able to accept the correct constructor arguments.) Because Regarding the possible syntax for passing type parameters to both a class and its constructor, I can't say I'm a fan of option A; it obfuscates the distinction between class and constructor type parameters (and I'm not sure there's a "natural" way to order them). Option B keeps the distinction, but has the same problem with "intuitive" ordering. C and D both look reasonable to me, though. Alternatively, perhaps a fifth option E where the constructor types are attached to the class Example<T> {
constructor<U>(classArg: T, constructorArg: U) {/* … */}
}
// This makes sense, right? It isn't just me?
const example = new<string> Example<number>(0, ""); |
@benblank I'm not certain, but #10860 (comment) made me assume that const example = new<string> Example<number>(0, ""); would not be possible/feasible. 😔 #40451 (comment) is another option, but it's pretty similar to Option D and is more or less a justification for that option. However, I would like not to be forced to specify a default value for the constructor's generic type parameters if possible (even if I'm never allowed to specify the types when using |
Search Terms
constructor
this
parameter
type
Suggestion
Allow constructors to specify a
this
parameter, and allow generic type parameters on constructors.Use Cases
Subclass Type Inference
The primary reason for this change would be to allow constructors to take in parameters that are in some way related to the
this
type of a subclass. Specifically, it provides a solution for the commonly requested pattern of initializing classes with all of their required properties:The huge advantage of this pattern is that the constructor does not have to be individually specified for every single subclass to maintain type safe initialization. In use cases where a class has many subclasses (e.g. various message types of a protocol that all share some common properties in the base class), the choice is between having manually typed constructors for each subclass and accepting the
any
type to be used withObject.assign
, which opens up various potential problems.Most other use cases are directly relevant in this example.
Single-Use Generics
Class methods use generics locally for situations where the inputs and outputs of the method are independent of the class itself.
While this might be less common with constructors, there are situations where generics are only necessary during initialization and become redundant afterwards. In these situations the types are not inherently tied to the overall class, so they should ideally be defined separately.
When referenced elsewhere in the codebase the class can then be free of generics clutter which was only necessary during initialization.
Reach-Through Self-Referencing Generics
Self-referencing generics (not sure this is the correct name) can be useful for obtaining information about a subclass, but they are only applicable if there is one layer of subclassing:
With constructor generics the following would become possible, even without
this
parameters:And when the
this
parameter is used, it becomes a way to reach directly to the furthest subclass, which self-referencing generics prevent. This is of course already available in ordinary methods.Decorators
Examples
Problems
Allowing constructors to have generic types does effectively create two sets of generics, which is an issue as mentioned here #10860 (comment), but a number of solutions exist to this problem:
Alternatives
The initialization pattern is also currently achievable using a static method.
Checklist
My suggestion meets these guidelines:
The text was updated successfully, but these errors were encountered: