-
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
Consider inferring class members types by implemented interface members (continuation of #340) #32082
Comments
I would worry that changing the rules for type inference would introduce cases where types would very subtlety change when only changing base classes which feels not ideal. #36165 suggests a way to just explicitly refer to the type expected in cases like this. |
@tadhgmister It is a very valid concern, which is why this proposal does not apply to base classes; only interfaces. |
I don't think that changes my concern, consider the following code: interface SomeInterface {
field: "A" | "B"
}
class A { // implements SomeInterface {
field = "A";
}
let x = new A();
x.field = "C"; Right now this code is perfectly valid, if we add This flow forces us to at least acknowledge that However with your proposal that intermediate step would be skipped, we add an extra implementation in A and the only error we'd get is somewhere down an inheritance line. We'd be asking the question ""why is assigning to "C" no longer valid? What should I be using instead?"" instead of asking the possibly much more relevant question ""does it still make sense for In most cases where adding a new implemented interface any changes to fields requires some sort of deprecation comments about how to change existing code to account for the new type and if the type is changing automatically without getting a programmer to ever look at the definition then I worry we would miss out on that addition. |
@tadhgmister In your example above, you write, "This flow forces us to at least acknowledge that A.field is changing types…" But that's exactly what is already implied when you add There's no need — and lots of inconvenience — to force developers to re-confirm their intent by explicitly typing |
@dtinth I'm curious to understand your rationale for properties inherited via class Animal {
parent = new Animal();
move() { }
}
class Dog extends Animal {
parent = new Dog();
woof() {
this.parent.woof();
}
} That code given is at best vague and—to me, at least—violates the principle of least surprise, especially for developers coming from other classical-OOP languages such as Java. Without an explicit type of I would propose that type inference for any inherited property (via extends or implements) be made as follows: A. Start by inheriting type from the ancestor classes:
B. Then restrict the above type by taking its intersection with the same property/method name of all of the interfaces listed in the implements clause of the class. It may turn out that the intersection becomes C. If both the ancestor classes and the interfaces have nothing to say about this property/method name, then its type is still That's my proposal. It ensures the greatest polymorphism by default, and I suggest the least amount of surprise. Yes, it's a breaking change, but I argue that TypeScript's current handling of the above code was already problematic, and letting that get in the way of simpler and better type inheritance is a mistake that has persisted too long. It seems to me that the TypeScript 4.0 release offers a good opportunity to introduce such a breaking change to set things right. |
@svicalifornia I try to minimize breaking changes in my proposal in order to make improvements more iterative, so that if the breaking change is deemed unacceptable, at least we can have some inference (for interfaces in this case), and that would be some step forward. I’m not opposed to adding breaking changes as your proposal said at all, and I agree that TS 4.0 offers a good opportunity for such changes. p.s. maybe your proposal should be a separate issue? |
This would absolutely not work. The highest ancestor isn't always assignable to the lowest one: class Animal {
parent = new Animal();
move() { }
}
class Dog extends Animal {
parent = new Dog();
woof() {
this.parent.woof();
}
}
class Greyhound extends Dog {
// are you suggesting we infer this as Animal?
// that isn't assignable to Dog.parent so this would just be an error, how is that useful?
parent = new Dog()
}
that is why I'm pushing for #36165, putting |
I'm saying that if an instance property in a subclass needs a different type than in its superclass, then that new type should be explicitly defined, like this:
Otherwise, it should inherit the type from its superclass, which maximizes polymorphism by default — in this case, making
But we shouldn't need to add an |
I was surprised to find that this wasn't already possible, especially given the fact that plain objects can be quickly stamped out using a complex type Hello = {
add(x: number, y: number): number;
}
let demo: Hello = {
add(x, y) {
return x + y;
}
} This allows the definition/contract to be written once & all implementations must then abide by it. However, with classes, all classes have to abide by the definition (of course), but each class definition has to redeclare the interface Hello {
add(x: number, y: number): number;
}
class Demo implements Hello {
add(x: string, y: string) {
return x + y;
}
//=> "Type '(x: string, y: string) => string' is not assignable to type '(x: number, y: number) => number'."
} AKA – TS already knows exactly what it's supposed to be. Requiring that the definition be inlined into every implementation means that the interface is really just an existence and a "repeat after me" check. The |
@JSMonk @MaxGraey Let's avoid this in Hegel. Here's a simple example: class Bar<Foo extends object> {
m!: Foo
method(arg: Foo) {}
}
class Test extends Bar<{n: number}> {
override method(arg) {} // Error, arg has type any, but shouldn't arg have type {n:number} ?
} Here's a bigger example showing the base class implementation details leaking to the subclass author, requiring duplication: type Options<T, O> = {
model: ModelBase<T>
n: number
} & O
type ModelBase<T> = {
s: string
} & T
class Bar<T extends object, O extends object> {
m!: ModelBase<T>
method(arg: Options<T, O>): ModelBase<T> {return {} as any}
}
// User experiences an error
class Test1 extends Bar<{b: boolean}, {s: symbol}> {
override method(arg) {return {} as any} // arg should have the expected type based on the template (Options<{b: boolean}, {s: symbol}>)
}
// Now the user has to duplicate
class Test2 extends Bar<{b: boolean}, {s: symbol}> {
override method(arg: Options<{b: boolean}, {s: symbol}>) {return {} as any}
}
// Or consolidate, but it is redundant
interface MyModel {b: boolean}
interface MyOptions {b: boolean}
class Test3 extends Bar<MyModel, MyOptions> {
override method(arg: Options<MyModel, MyOptions>) {return {} as any}
} This is not ideal because now the end user has to know how to apply template types in the same way as the base class, despite that the subclass method is already constrained to the base class method type to begin with. |
Not only that, but if you abandon class syntactic sugar (yes, not exactly syntactic sugar, but close), then you get much better TypeScript support. Classes turn out to the least supported primitive in TypeScript. For example, if you use this classic approach... export interface Print {
prototype: {
print(txt: string): void;
}
}
/** @class */
export const TextBook: Print = function() {}
TextBook.prototype.print = function(txt) {} Then in the above example, the |
Could be skipped. You can still add explicit types. This decision should be at the developer level, not the language. It's the perfect variance case. A new keyword is just noise. To me it's pretty clear that
|
Continuation of #340, #1373, #5749, #6118, #10570, #16944, #23911.
Why a new issue?
Many previous issues discussed about having contextual types based on both extended base class members and implemented interface members. This proposal only applies to
implements
, notextends
.Discussions in #6118 ends with an edge case when a class extends a base class and implements another interface. I think this problem would not occur if the scope is limited to
implements
keyword only, and I believe would be a step forward from having parameters inferred asany
.Previous issues have been locked, making it impossible to continue the discussion as time passes.
Proposal
Using terminology “option 1”, “option 2”, “option 3” referring to this comment: #10570 (comment)
extends
keep option 1.implements
:Examples
Code example in the linked comment:
Since only the
implements
keyword is considered,item
will be inferred asSubitem
.Example in #10570 (comment)
Example in #16944:
Example in #340:
Checklist
My suggestion meets these guidelines:
The text was updated successfully, but these errors were encountered: