microsoft/TypeScript

Constructor generic types and `this` parameter (TS1092, TS2681)

nebkat opened this issue · 6 comments

Search Terms

constructor
this
parameter
type

Suggestion

Allow constructors to specify a this parameter, and allow generic type parameters on constructors.

  • Remove TS1092: Type parameters cannot appear on a constructor declaration.
  • Remove TS2681: A constructor cannot have a 'this' parameter.

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:

class Base {
    foo!: boolean;
    constructor<T extends Base>(this: T, data: T) { // or data: Partial<T>, etc
        Object.assign(this, data);
    }
}
class A extends Base {
    bar: number = 0;
}
class B extends Base {
    baz: string = 'hello';
}
new A({foo: true, bar: 123});
new B({foo: false, baz: 'world'});

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 with Object.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.

class Foo {
    constructor(foo: Foo, options: FooOptions);
    constructor<T extends Bar>(bar: T, options: BarOptions<T>); // make sure BarOptions are specific to T!*
    constructor(fooOrBar: Foo | Bar, options: FooOptions | BarOptions<any>) { // don't care at runtime anyway
        if (fooOrBar instanceof Foo) {
            this.applyFooOptions(options);
        } else {
            this.applyBarOptions(options);
        }
    }

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:

class Base<T extends Base<any>> {
    constructor(data: T) {
        Object.assign(this, data);
    }
}

class Foo extends Base<Foo> {}
new Foo({});

class Bar extends Base<Bar> {
    barInfo: string;
}
new Bar({barInfo: "test"});

// Bar is a standalone class so it must set the generic, but it also has a subclass that adds some extra info
class BarAndMore extends Bar { // bar has no generics, can't change Base<Bar> constructor parameter ?
    barExtendedInfo: string;
}
new BarAndMore({
    barInfo: "test",
    barExtendedInfo: "more" // ERROR: Object literal may only specify known properties, and 'barExtendedInfo' does not exist in type 'Bar'.
});

With constructor generics the following would become possible, even without this parameters:

new BarAndMore<BarAndMore>({...barAndMore});

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

class Base {
    constructor<T>(this: {id: T}, id: T) { this.id = id; }
}
class A {
    @IsNumber() id: number;
}
class B {
    @IsString() id: string;
}
new A(123);
new B(123); // ERROR: Argument of type 'number' is not assignable to parameter of type 'string'.

Examples

export type TypeKeys<T, U> = {
    [P in keyof T]: U extends T[P] ? P : never;
}[keyof T];
export type FunctionKeys<T> = TypeKeys<T, (...args: any[]) => any>;

class Base {
    def = true;

    foo!: boolean;
    constructor<T extends Base>(this: T, data: Omit<T, 'def' | FunctionKeys<T>>) {
        Object.assign(this, data);
    }

    test() {}
}
class A extends Base {
    bar: number = 0;
}
class B extends Base {
    baz: string = 'hello';
}
new A({foo: true, bar: 123});
new B({foo: false, baz: 'world'});

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:

class Foo<T> {
  constructor<U>(x: T, y: U) { }
}

new Foo<number, string>(0, '') // A: concatenate generics
new Foo<number><string>(0, '') // B: separate type arguments
new (Foo<number>)<string>(0, "") // C: wrapped generic class

// https://github.com/microsoft/TypeScript/issues/10860#issuecomment-300918682
class Bar<T> {
  constructor<U = string>(x: T, y: U) { }
}
new Bar<number>(0, '') // D: only allow constructor generics if a default is provided

Alternatives

The initialization pattern is also currently achievable using a static method.

class Base {
    foo = false;

    // To prevent user from initializing directly request impossible parameters, since protected does not work
    constructor(internalGuard: never) {}

    static construct<T extends Base>(
            this: new (internalGuard: never) => T,
            params: T) :T {
        return Object.assign(new this(<never>undefined), params);
    }
}
class A extends Base {
    bar!: number;
}
A.construct({foo: true, bar: 1});

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, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

I really appreciate the super-clear use case examples here

jcalz commented

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 @DanielRosenwasser. But just in case you were asking me, I'm providing thoughts now because I have the time to provide them right now.)

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.

This is actually my exact use case.

The Use Case

I'm working on a utility that will allow developers to run sophisticated logic with event listeners. Let's call it the ElementObserver. (I will be providing a reduced, overly-simplified version of the use case here. So for now, please just trust that document.addEventListener and document.removeEventListener are insufficient, and that something like this class is needed.) This observer will basically register/unregister event handlers for an "observed" element as needed. Multiple elements can be observed at a time.

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 (observe/unobserve/disconnect) never has to change, and everything functions predictably. So it would be inappropriate to make the class generic. (In fact, using a generic class instead of a generic constructor would cause things to break.)

Instead, the constructor needs to be generic. This is because the type of Event expected by the listener(s) argument must be chosen based on the kind of EventType being listened for. Without this feature, the project that I'm working on will provide an inferior user experience. (I'm using JSDocs. And within that domain, there are workarounds to "get generic constructors". But each workaround has some kind of disadvantage, depending on what's used. I can go into more detail if needed.)

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 FormObserver class. Until this and/or #55919 are resolved, my package will have some broken types. (Note: I don't want to navigate away from JSDocs.)

Limitations of the Interface + Class Expression Approach

I know that generic constructors can be "simulated" by using an interface together with a class expression, but there are several limitations to this. Here are 3:

1) Separation of Documentation and Implementation

The 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 interface + class expression approach is used, the documentation is separated from the implementation. This is because the documentation cannot be supplied by the methods inside the class definition. (Trying such a thing does nothing.) Instead, all of the documentation is only acknowledged/preserved when it is provided by the interface.

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 Overloads

The 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 implements clause. But when it comes to interfaces that define a class with a generic constructor, this is actually unsafe. As far as a class with a generic constructor is concerned, it wants to state that the argument to method must exclusively be a string, or it must exclusively be a number. It cannot be a union of the two. But when MyClass implements MyInterface, everything checks out to TypeScript. If someone accidentally provides this method declaration within their class definition, they can end up writing code that runs on invalid assumptions without being warned -- resulting in bugs.

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 interface + class expression approach.

3) Duplication of Type Information

In 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 Concerns

One 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, U, would be inferred from the arguments to the constructor, it will never really be necessary for consumers to supply the type arguments for generic constructors anyway. If the generic type is so unimportant that the argument using it is optional, then the class author can provide a default type value instead (or let the type default to unknown by itself).

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 Type

In 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 S, only belongs to method. It is irrelevant to the rest of the class, and it should not be accessible to the rest of the class. In the same way, the constructor type parameter, U, is only relevant for the constructor. Consequently, the rest of MyClass's methods and fields should not be able to access U. (Just like the rest of the class can't access S.)

If this logic holds, then it's also true that consumers have no need to specify U as a type parameter. How so? Because the consumer gains nothing from specifying U. Immediately after the consumer specifies the type parameter, they no longer need it because the type parameter has no impact on the class's actual interface. If they choose to specify the type, it will only be relevant for the argument that they provide for the constructor. But U is already inferred from the arguments anyway.

User-Provided Type Arguments Are Only Relevant When the Author Can't Predict the Outcome

I 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, document.querySelector can only anticipate the returned HTMLElement type if the string provided to it is a raw tag name recognized by the web spec. Otherwise, the best that the method can do is default to HTMLElement. It is possible that document.querySelector(".my-class") should produce an HTMLSpanElement, but the author could never know that. A similar story goes for the Testing Library's getByRole query.

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 Notes

Because 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: T for the class and U for the constructor. Then, they can try to set things up such that T is derived from U in some way. However, I'm not sure how reasonable, practical, or common this use case would be. So I don't think it should be a concern that impacts the decision on this matter.