Skip to content
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

Allow overloading constructors with type parameters to instantiate the same class but with different generics #54157

Open
5 tasks done
ChiriVulpes opened this issue May 5, 2023 · 16 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@ChiriVulpes
Copy link

ChiriVulpes commented May 5, 2023

Suggestion

πŸ” Search Terms

constructor overload return type parameter annotation generic

βœ… 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.

Previous Discussions & Related Issues

  1. TS1092: Type parameters cannot appear on a constructor declarationΒ #10860
    The closest to a duplicate of this proposal. Closed due to conflicts between generics on class and generics on constructor overloads.

  2. TS1093: Can't specify return types on constructorsΒ #11588
    A feature request asking for TypeScript to allow setting any return type for a constructor. It was declined due to that pattern being considered bad practice.

  3. Feature Request: Declared constructor return typesΒ #27465
    Quickly closed due to being a duplicate of the first issue, #10860, did not end up with much further discussion once it was closed, unfortunately.

  4. Allow to specify return type of constructorΒ #27594
    Asking again for TypeScript to allow setting the return type for a constructor. From what I can see, the issue does not specify whether the instance type should be assignable to the return type, or whether any return type should be allowed (IE, a straight rehash of #11588.) This issue is still open and has garnered a lot of discussion, but it's only tangentially related to this proposal.

⭐ Goals of this Proposal

1. Allow writing classes that generate the correct generics without requiring the consumer to provide any.

The ideal circumstances for class construction with generics is the following, where the consumer doesn't need to provide any type parameters:

// Ideal circumstances:
class Collection<T> {
  constructor(iterable: Iterable<T>) {}
}
new Collection([1, 2, 3]) // Collection<number>

That, unfortunately, is not always possible. In the following example, for instance, the ideal return would use HTMLElementTagNameMap[TAG_NAME], but there's no way to do that:

class ElementWrapper<TELEMENT extends Element = Element> {
  constructor(tagName: string) {}
}
new ElementWrapper("button") // ElementWrapper<Element>
new ElementWrapper<HTMLButtonElement>("button") // ElementWrapper<HTMLButtonElement>, but the consumer has to annotate it themself :(

2. Don't affect existing TypeScript code at all. Any new functionality should be layered over top what's already there.

When a developer isn't using any of the newly-allowed annotations, TypeScript should function exactly as it does now.

3. Don't introduce new syntax for providing type parameters to a constructor call.

4. Disallow constructors to be annotated as returning things not assignable to the class's type.

Returning anything is out of scope for this proposal.

πŸš€ Proposal

Constructor overload signatures can now have their own type parameters and provide a return type.

In order to allow this while still respecting the other stated goals above, this comes with the following restrictions:

  • When type parameters are specified, and the overload is called, the overload's type parameters are used instead of the class generics.

  • When a signature is using custom type parameters, it must, therefore, have a return type, otherwise there wouldn't be a way for TypeScript to determine the generics of new instances.

  • Any constructor return type must be assignable to the class's instance type. (As stated above, anything else is out of scope for this proposal.)

  • Again, only overloads may have type parameters or return types. The constructor implementation may not have type parameters or a return type, as it adds additional complexity to the proposal. The current errors will become Type parameters can only appear on overloaded constructor declarations. and Type annotations can only appear on overloaded constructor declarations.

Example

Using the above ElementWrapper example, we can now do the following:

class ElementWrapper<TELEMENT extends Element = Element> {
  constructor<TTAG_NAME extends keyof HTMLElementTagNameMap>
    (tagName: TTAG_NAME): ElementWrapper<HTMLElementTagNameMap[TTAG_NAME]>;
  constructor(tagName: string) {}
}

Constructor overloads can use different type parameters from each other.

The functionality exactly mirrors function overloads, and allows for the following:

class ElementWrapper<TELEMENT extends Element = Element> {
  constructor<TTAG_NAME extends keyof HTMLElementTagNameMap>
    (tagName: TTAG_NAME): ElementWrapper<HTMLElementTagNameMap[TTAG_NAME]>;
  constructor(tagName: string): ElementWrapper<Element>;
  constructor(tagName: string) {}
}

Constructor overloads should not be handled any differently in TypeScript than they are now (yes, it already works!), using the complex newable interface syntax. (To note, while that syntax works for type definitions, if a bit awkwardly, trying to implement the class itself using those types is even more awkward.)

@MartinJohns
Copy link
Contributor

How would you specify the type of the constructor argument when invoking? new Ctor<T>() is already used to specify the class type.

@ChiriVulpes
Copy link
Author

ChiriVulpes commented May 6, 2023

As stated in the proposal:

  • When type parameters are specified, and the overload is called, the overload's type parameters are used instead of the class generics.

  • When a signature is using custom type parameters, it must, therefore, have a return type, otherwise there wouldn't be a way for TypeScript to determine the generics of new instances.

Here's some examples to maybe make it a bit more clear:

  • If Ctor has constructor<R>() {}, a compile-time error is thrown because TypeScript won't know what the generics of the constructed class instance would be.

  • If Ctor has constructor<R>(): Ctor<R[]> {}, calling new Ctor<T>() returns Ctor<T[]>

@MartinJohns
Copy link
Contributor

That makes sense. My brain must have filtered that part when reading the issue.

@fatcerberus
Copy link

fatcerberus commented May 6, 2023

Since the type of the rest of the class depends on the return type of the constructor and not the type parameters used to construct it, this feels like it will make things very difficult (as in, having to ts-ignore or cast away lots of type errors in the constructor) if you do anything more complex than, e.g. transforming a T into a T[] or similar. It's all the fun of conditional return types, magnified about 100x because now those same limitations extend to every instance of a type parameter in the class body.

@ChiriVulpes
Copy link
Author

ChiriVulpes commented May 6, 2023

Since the type of the rest of the class depends on the return type of the constructor and not the type parameters used to construct it, this feels like it will make things very difficult (as in, having to ts-ignore or cast away lots of type errors) if you do anything more complex than, e.g. transforming a T into a T[] or similar. It's all the fun of conditional return types, magnified about 100x because now those same limitations extend to every instance of a type parameter in the class body.

Would you mind giving an example? I’m not sure I'm following. All the fields and methods in a class would use the class generics as it works now, they wouldn't have to know about any constructor return types. This stuff is purely for consumers constructing a class.

@fatcerberus
Copy link

fatcerberus commented May 6, 2023

I wasn't talking about the methods, but the constructor. Initializing the class would involve the same challenges as a function with a complex generic return type.

See for example #33912

@fatcerberus
Copy link

(note that conditional types can sneak in where you don't expect them - ReturnType<> is implemented as a conditional type, for example.)

@ChiriVulpes
Copy link
Author

ChiriVulpes commented May 6, 2023

Hmm, I don't think I included enough description on this topic in the proposal. The idea in this case is that the constructor body would function exactly the same as it does now -- it would act as though it's executing as the most generic form of the class, regardless of any inputs or the specified return type. I’m not even arguing for any sort of additional or modified return type checking in the constructor implementation. (I don't like the returning from constructor pattern.) This proposal is solely syntactic sugar for the callable constructor signatures, not for anything on the implementation side.

@fatcerberus
Copy link

fatcerberus commented May 6, 2023

Okay so concretely, if you write a constructor signature that goes something like constructor<T>(x: T): C<Transform<T>> {} where Transform<T> runs into similar limitations as described in #33912… how do you propose that should be dealt with in the implementation of the constructor?

@ChiriVulpes
Copy link
Author

ChiriVulpes commented May 6, 2023

Okay here's a concrete example, say I'm making a wrapper class for an enum value, but the constructor accepts enum keys:

enum AnimalType {
  Unknown,
  Dog,
  Cat,
  Bird,
}

class Animal<TAnimal extends AnimalType> {
  public readonly type: TAnimal;
  
  public constructor<TAnimalName extends string>(name?: TAnimalName): Animal<TAnimalName extends keyof typeof AnimalType ? (typeof AnimalType)[TAnimalName] : AnimalType.Unknown> {
  
    // `this` here resolves to `Animal<AnimalType>`, ie, as generic as possible
    // I have access to the following types:
    // - TAnimal (which resolves to `AnimalType`)
    // - TAnimalName (which resolves to `string`)
  
    this.type = AnimalType[name as keyof typeof AnimalType] ?? AnimalType.Unknown;
  }
}

And here's an alternate version where I have overloads, too:

class Animal<TAnimal extends AnimalType> {
  public readonly type: TAnimal;
  
  public constructor<TAnimalName extends string>(name?: TAnimalName): Animal<TAnimalName extends keyof typeof AnimalType ? (typeof AnimalType)[TAnimalName] : AnimalType.Unknown>;
  public constructor<TAnimalType extends AnimalType = AnimalType.Unknown>(type?: TAnimalType): Animal<TAnimalType>;
  public constructor(typeOrName: string | AnimalType) {
  
    // `this` here resolves to `Animal<AnimalType>`, ie, as generic as possible
    // I have access to the following types:
    // - TAnimal (which resolves to `AnimalType`)
  
    if (typeof typeOrName === "string") {
      this.type = AnimalType[name as keyof typeof AnimalType] ?? AnimalType.Unknown;
    } else if (typeof typeOrName === "number") {
      this.type = typeOrName;
    } else {
      this.type = AnimalType.Unknown;
    }
  }
}

In these examples I don't include return lines that have to be checked against, because they'd work like they do now. I think you're assuming that the return type of the constructor would be used for this in the constructor body, and for checking the value of return, but I don't think it should. I think the way that works now should stay entirely the same. I think adding additional checks there would be a separate proposal.

@ChiriVulpes
Copy link
Author

ChiriVulpes commented May 6, 2023

If it's too confusing that the return type is not used for checks in the implementation, perhaps we could require that only overloads can have type parameters and return types?

For example, in the initial example I gave (copied below) the constructor would error with tweaked versions of the current errors: Type parameters can only appear on overloaded constructor declarations. and Type annotations can only appear on overloaded constructor declarations.

class ElementWrapper<TELEMENT extends Element = Element> {
  constructor<TTAG_NAME extends keyof HTMLElementTagNameMap>
    (tagName: TTAG_NAME): ElementWrapper<HTMLElementTagNameMap[TTAG_NAME]>
  {}
}

Whereas this following one would work fine:

class ElementWrapper<TELEMENT extends Element = Element> {
  constructor<TTAG_NAME extends keyof HTMLElementTagNameMap>
    (tagName: TTAG_NAME): ElementWrapper<HTMLElementTagNameMap[TTAG_NAME]>;
  constructor(tagName: string) {}
}

EDIT: I've updated the proposal to only allow type parameters and return types on overloads to resolve this issue.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels May 15, 2023
@robbiespeed
Copy link

robbiespeed commented Sep 18, 2023

This would be very useful even without overloaded type parameters.

class Box<T = unknown> {
  value: T;
  constructor (): Box<unknown>;
  constructor (value: T): Box<T>;
  constructor (value: T) {
    this.value = value;
  }
}

const box = new Box();

As it stands it's not really possible to make such class without resorting to creating a separate constructor interface, but when you do that you can't have consistent naming of constructor, and instance interface.

Edit:

It is possible to achieve the desired behaviour from my above example today:

class Box<T = unknown> {
  value: T;
  constructor ();
  constructor (value: T);
  constructor (value?: T) {
    this.value = value!;
  }
}

const box = new Box(); // type is Box<unknown>

@meszaros-lajos-gyorgy
Copy link

meszaros-lajos-gyorgy commented Dec 17, 2023

I would love to see @robbiespeed's example stripped down even further, like this:

class Variable<T> {
  value: T

  constructor(value: number)
  constructor(value: string)
  constructor(value: T) {
    this.value = value;
  }
}

const x = new Variable(3);

In the code above I would expect x to be Variable<number>, but currently it's just Variable<unknown>.

@robbiespeed
Copy link

@meszaros-lajos-gyorgy I've updated my above example, as it is possible today.

Your example can be achieved just by removing the overloads. Though more complex examples are also possible today like:

class Variable<T> {
  value: T

  constructor(value: T extends number ? T : never, type: "number")
  constructor(value: T extends string ? T : never, type: "string")
  constructor(value: T)
  constructor(value: T) {
    this.value = value;
  }
}

const foo = new Variable("", "string"); // type is Variable<"">
const foo2 = new Variable(2, "string"); // type error
const foo3 = new Variable({ bar: 1 }); // type is Variable<{ bar: number }>

@meszaros-lajos-gyorgy
Copy link

meszaros-lajos-gyorgy commented Dec 26, 2023

@robbiespeed thank you very much for the info, this T extends type ? T : never pattern seems to work, but I get something weird:

class Variable<T> {
  value: T

  constructor(value: T extends number ? T : never)
  constructor(value: T extends string ? T : never)
  constructor(value: T) {
    this.value = value;
  }
}

const x = new Variable(3); // x = Variable<3> instead of Variable<number>
x.value = 4 // Error: Type '4' is not assignable to type '3'

Is there a way to enforce the type Variable<number> without having to construct the class with something like const x = new Variable(3 as number) ?

@robbiespeed
Copy link

@meszaros-lajos-gyorgy yes there is, the type condition needs to be reversed from T extends string to string extends T here's a playground link that supports both string and string literals. Though if you are truly trying to achieve something as simple as your example there you should add a constraint to the type param of the class rather than do an overload (SimpleVariable in the playground).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

6 participants