-
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
Proposal: structural overloading for interfaces #4302
Comments
Seems like there are two ideas here. One is a new kind of heritage clause that is slightly different from extends, but is similar in spirit. I think that would be feasible, but why would it be useful? The second idea is a type operator that takes any two types, and creates a new type that inherits from both operand types? But it is not obvious how this operator would work when the operands are type parameters, union types, primitives, etc (anything that is not an object type). |
For a start, I first wanted to consider what is the actual scope and character of the "overloads" heritage relation (not the operator). The first thing that I wanted to check is if this applies to anything else than interfaces (I'm including function and hybrid object/function types here, not just object types, I will demonstrate that later). Primitives (and possibly unions) would not fit of course, because they don't have an internal structure. In the cases of classes, it will violate OOP principles by allowing a derived type to have the same property of a different type than defined on a base type, so the base type cannot assure the type (or possible range of types) that the property is assigned to. Hypothetical (problematic) example: class Base {
x: boolean;
}
class Derived overloads Base {
x: number;
}
let derivedInstance = new Derived();
derived.x = 53;
let baseInstance: Base = derivedInstance;
let expectedBooleanValue = baseInstance.x; // Expected: boolean type. Wrong! x is actually a number. So classes are also not really an application for this. Now even when constrained to the scope of interfaces. Should this actually be even described as inheritance? (at least with the analogy from classes?). I don't know, maybe it does, maybe not, so for caution, I changed the title to "structural overloading for interfaces", just to be on the safe side. This is all quite challenging for me, so I will try to address more of the presented issues later, like possible use cases (that I can definitely think of, this does describe a possible pattern that can appear in dynamic languages) and more about the operator I mentioned. |
An example of how this would work with two hybrid function/object interfaces (and also applies to cases where only one is hybrid, as well): interface A {
(num: number): string;
x: boolean;
}
interface B overloads A {
(str: string): number;
x: number;
}
interface StructurallyEquivalentToB { // Demonstrate B after the structural overloading is applied.
(num: number): string;
(str: string): number;
x: boolean | number;
} |
Before attempting to go into the details of the operator, there is first a consideration of a theoretical issue with a class that implements an overloaded interface. This is more of a question for language experts. The question is: Consider a class that implements an interface (and doesn't add anything of its own), and another similar class that implements an interface that is hierarchically lower than the other one (I'm intentionally avoiding using the word "derived" here). According to the principles of OOP: Would the first class be automatically considered a super-type of the other one? For example: interface BaseInterface {
x: number;
}
interface OverloadedInterface overloads BaseInterface {
x: string;
}
class SomeClass implements BaseInterface {
x: number = 53;
}
class SomeOtherClass implements OverloadedInterface {
x: number|string = "Hello World!";
} Is Or alternatively, there's a simpler possible problem with this: interface BaseInterface {
x: number;
}
interface OverloadedInterface overloads BaseInterface {
x: string;
}
class SomeClass implements OverloadedInterface {
x: number|string = "hello!";
}
let classInstance = new SomeClass();
let overloadedInterfaceInstance = classInstance;
// This assignment is *not* structurally correct, but may work because of their
// positions in the hierarchy.
let baseInterfaceInstance = overloadedInterfaceInstance;
// According to the base interface, x is of type number, not string|number so this
// may incorrectly assume a wrong type.
let x = baseInterfaceInstance.x; If these are real problems, then |
First I would explain why I considered defining a hierarchical relation (which does seem to have its problems and may not turn out to be a good idea). It is not just to explore its use but also to try to consider on how to integrate the operation more strongly and consistently into the language (and observe things like OOP issues etc.). This may turn out to only be a hypothetical experiment (as the problems I showed seem possibly major), but I find it useful. The idea of the structural overloading operator is to try to capture the intuitive concept of "merging" types structurally, similarly to what is achieved by intersection, but without being bound to strict and abstract math thus more directly reflecting the intention of the programmer both in practice and theory. I didn't intend to define it for anything other than objects, functions and object/function hybrids. I currently don't feel it would be a good idea to enlarge its scope even further, say also to primitives or unions, but that might be a future area to explore. In the case of objects: the main purpose of the operation is to capture the type equivalent of object structural merging (with a logic similar to jQuery.extend, at least when the operation is not a "deep"/recursive one), for example: let a = {a: 54, b: "hello", c: true};
let b = {b: 991, c: undefined}
// Now merge b into a while avoiding copying undefined values:
let result = {a: 54, b: 991, c: true} The equivalent type operation would reflect the possible values after the objects are merged: interface A {a: number, b: string, c: boolean}
interface B {b: number, c: string}
type Result = B overload A // results in {a: number, b: string|number, c: boolean|string} So in practice it might seem to behave exactly like intersection, at least for most cases. The major (and very important) difference it that it's 100% backwards compatible with inheritance (while the opposite is not true in general). For every two types that do not fail on: interface A extends B,C {} it would also not fail and derive the exact same type (and is additionally guaranteed never to fail if B and C are known to be object/function/hybrid interfaces): type A = C overload B; // Can also be written as B overload C - it is a commutative operation. The "overload" name is given just for illustration. I will try to think of a symbol that would make it more concise. |
I think the key problem is the one you've mentioned in #4302 (comment). This problem exists for interfaces too. I don't think the assignment So I think what would be feasible is a heritage clause that says, "my new type is just like this old type, but with the following exceptions". It would not be guaranteed that the derived type is assignable to the base type. It may have no meaningful relation at all to the base type, but would just use the base type for inspiration. I still think it is more useful to combine the use of intersection types and "extends" than it is to introduce an operator that is half way in between. |
Yes I have detected and explained the problem myself in detail. It does not maintain assignment integrity that is consistent with the position in the hierarchy, so it doesn't seem to be suitable as a hierarchical operation. Exploring this problem is an important part of considering the introduction of the operator. Please don't use that as a way to discount the concept, because it has no effect on its veracity as an operator (the same problem would occur with intersection). I disagree with intersection having any benefit over this operator. It is not an "in between" operator. Unlike intersection, It is a variation of multiple inheritance that was designed specifically for the "merging" of objects. It is backwards compatible with inheritance (and will always be, including with future changes to the lanaguge) and works with hybrid and strict types (which are theoretically incompatible with intersection). It is conceptually simpler and easier to understand than intersection. It allows significant reuse of code in the compiler. Your statement was general and subjective and not backed up by well founded arguments. I would like to hear a more detailed and logical explanation (not opinion) of why you think intersection is more desirable than this (or some future adaption of it). I would also need to hear a similar set logical and well-founded set of arguments from @ahejlsberg (again, not opinion). If the points that are given would be insightful and helpful. I will try to address them and modify/improve the design. Constructive criticism is always welcome. |
I just realized I was mistaken by assuming overloading properties assigned function types is already possible (or even ever would be) through inheritance: interface A {
func(arg: string): boolean;
}
interface B extends A {
func(arg: number): string;
} // Error! Interface 'B' incorrectly extends interface 'A' Allowing this would have the same assignability problem as with properties because overloading essentially "widens" the types (both properties and function signatures) so it would break the expected supertype-subtype relation correlation with position in the chain. As a non-expert it was not initially obvious for me to notice that.. Anyway, I've been observing the discussions and suggestions related to intersection on the site and it seems to me almost everyone (except perhaps the designers themselves) misunderstand intersections to some degree, and some seem to propose things that imply stretching or even "abusing" the math at places:
Typescript Design Goal:
Honestly, it looks more like it requires a Ph.d just to get around the theory here, when also considering the inherent complexities of the language itself (try explaining intersection types to high-school students.. good luck with that..). Also I think that's unavoidable that people would keep viewing and using intersection as (I wouldn't say "primarily" but that may actually turn out to be the case..) a way to perform "ad-hoc" multiple inheritance. Although now having local types declarable in functions would reduce that, but only by a little: function test() {
interface C extends A,B {}
let x: C;
} This would still not address the need to have something equivalent for class/interface property types, return types, function arguments, generic types etc. My proposed operation encompasses virtually all the functionality of multiple inheritance and intersection, in one operator. It is not an in-between, it is both, and possibly more (also, this better addresses the design goal: "produce a language that is composable"). I feel like I've already done all I can (and probably well over and beyond what I should have). I was just trying to do my best to help make the language better (for my own, and everyone's benefit). Without your cooperation, that would be pretty much impossible. |
I am not sure what 'composable' means here. The concept of overloading has to do with aggregating signatures. It is not an operation on types, it is an operation on signatures. Overloading means that something is capable of being called or new-ed in multiple ways. You are right that inheritance with methods (like your func example) follows the property model of inheritance, and not the signature model. Namely, the method from the derived class completely replaces the base method. @rotemdan I think the issue is that you are proposing and discussing so many ideas at the same time. It is easier to deal with questions / issues / confusions / requests one by one, instead of all at once. |
@JsonFreeman The main intention of this proposal, plain and simple, is to provide an example (or at least a direction) of what could replace intersection. I'm not a world class language expert of the level of yourself and the people you work with. I cannot provide you with detailed specifications of how to achieve and work out the problems that may occur. I'm not familiar with the compiler code and I never took part in design meetings (especially the ones that discussed intersection). If I make a mistake that looks fundamental or basic to you, or I mention I'm not sure about something, I guess that would mostly demonstrate that I'm not an expert. It's nice of you to try to help, but that's is not really the point here. Again, I didn't open this issue to understand intersection better. I opened it to encourage people to consider its alternatives. If intersection is released to Beta soon, then I guess have failed and there's no point in continuing. After all, this is Andres' feature. If anything I mentioned here (and at other threads) made any impact or even ringed a bell. He could easily come up with a replacement. I don't think any one of you would need my assistance. |
That is fair. I understand that the goal of the proposal is to replace intersection because there are people who find it unintuitive. And I understand that you have thought about several flavors of alternatives based on other models that are used in the language. I do not believe intersection is likely to be replaced. There is no concrete evidence of it being limiting to users in terms of expressiveness and utility, only that several users are more familiar and comfortable with other concepts instead. In some cases, that is enough to oust or deprecate a feature, and in some cases it's not. My hunch is that in this case it is not, particularly because the design and interaction with the rest of the type system would have to be worked out again with the alternative feature, and is likely to have challenges that differ from those of intersection. So my hunch is that it will not get replaced, but @ahejlsberg will have an opinion. |
I appreciate your response. I disagree that the word "deprecate" should be used with the feature because as of this time it is not a production feature (or even Beta - though it seems like that would be close). I value the work and thought that has been done to design and integrate it but I thoroughly believe it is not the best fit for the language (and not a great idea in general). I have already expressed so many of the issues and concerns I have with it (and again, not all of them were about problems with intuition and understanding, some are about "impedance mismatches" with inheritance, strict/hybrid types etc). Some of that was done when the code was still at the state of a pull request (I only became aware of intersection types when they were added to the roadmap, unfortunately, that might have been a bit too late, because the code was already written). There's an interesting idea that I've been considering with this proposal, that I haven't mentioned yet, and is strongly related to the issue of backwards-compatibility and composability (which you asked about): Let If This will increase composoability because At this point (and with my level of expertise) I can't really predict if this would turn out to be a great idea, but I think it demonstrates just how creative one can be when a more open-ended toolset is available. [I'm aware of my tendency of "bundling" unrelated ideas and questions together. I am trying to improve on that but in this case don't feel that opening more issues would be a good idea]. Edit: I had an unintended mistake: by |
I just realized that I for the specific case of "merging" of objects like is done with interface A {
func(number): string;
func(number): number;
}
interface B {
func(string): boolean;
}
interface FunctionMergingWithOverloading {
func(number): string;
func(number): number;
func(string): boolean;
}
interface FunctionMergingWithUnions {
func: { (number): string; (number): number; } | { (string): boolean };
} The version with unions would probably be more correct for assignablilty in that case: either to, or from (with a cast). The version with overloading would still be useful though, but for other cases, such as extending an old API to a slightly modified newer one (say, one that adds some overloads to a single function) without needing to completely restate the older one. I realize that might seem obvious for experts, but reasoning about types is not something I do daily. Anyway, I didn't consider that a critical detail. I should probably change the title to "Structural merging" though, or maybe even start a new proposal for it. |
Overloading and unions mean different things. A type with overloads (like FunctionMergingWithOverloading in your example) means that the value can be called in any of those three ways, and the caller gets to choose which one to use at any time. A union of function types on the other hand means that the value might be of type A or of type B when the caller obtains it, but the caller is not sure which one it got. In cases like that, the caller really can't assume that any signature exists on the type, and needs to inspect the value further to find out if it was really an A or a B. In light of this, are you sure jquery.extends gives you a union? Normally when you extend an object with additional functionality, you produce an intersection (which for function types means overloads). |
The difference seems subtle (at least for a non-expert like me). But I realized that to simulate the type equivalent of the merge operation (as applied by
[By "arguments" in the last sentence they probably meant "properties"]. So for the case: interface A {
prop: number;
func(arg: string): boolean;
}
interface B {
prop: string;
func(arg: number): string;
} let a: A = { prop: undefined, func: (arg: string) => true }
let b: B = { prop: "hello", func: (arg: number) => "abcd" }
A different case: let a: A = { prop: 42, func: (arg: string) => true }
let b: B = { prop: "hello", func: undefined }
Modeled with overloading, the resulting type would look like this: interface WithOverloading {
prop: number | string;
func: {
(arg: string): boolean;
(arg: number): string;
}
}
let a: A;
let b: B;
let merge: WithOverloading; The problem here is that it is not possible to assign The alternative approach with unions would look like this: interface WithUnions {
prop: number | string;
func: { (arg: string): boolean } | { (arg: number): string };
}
let a: A;
let b: B;
let merge: WithUnions; Now it is possible to assign |
Re-reading this, maybe they did actually mean "arguments" literally? (so in that sentence they referred to the arguments for the
Source: jQuery.extend() documentation. |
I think the semantics of this jquery.extends operation are very specific. It seems like it chooses which object to copy from, on a per property basis. The closest thing we have in the language to model this is unions, as you point out. But this {
prop: number;
func(arg: string): boolean;
} |
{
prop: string;
func(arg: number): string;
} is not the same as this {
prop: number | string;
func: { (arg: string): boolean } | { (arg: number): string };
} because in the first case, you can't have an object where prop is a number, but func takes a string. The tricky thing is that if you introduce a type operator to typescript, you have to think about whether it can occur in a syntactic context where a type parameter can appear as an operand. If it can, then you either have to explicitly require that it only be applied to object types (for example the extends operator), or you have to allow it for all types (for example union types). The reason is that you don't know what the type parameter will be instantiated to. I realize this seems like an abstract concern, but it is a very real phenomenon. |
I'll also add that in terms of non-overlapping properties, the merging operation you're describing aggregates them in an intersection-like manner, rather than a union-like manner. This means that the operation you're looking for at times resembles intersection and at times resembles union. |
I'm aware of the differences between (whole) unions of object types and structural union of their properties. Although I may not have the expert level view to understand its theoretical implications. The reason I was trying to model In my own code I use a similar object merging method but in terms of types the arguments interface Options {
startIndex?: number;
endIndex?: number;
loop?: boolean;
callback?: (number) => boolean;
}
function SomeAction(userOptions: Options) {
let defaultOptions: Options = {
startIndex: 0,
endIndex: undefined,
loop: false,
callback: undefined
}
// The merged object and arguments will all have the same types here,
// although it is possible that the extendObject function would generate an object having
// a different type that virtually never happens in practice.
userOptions = extendObject<Options>(userOptions, defaultOptions)
} I never felt a real-world need for a specialized "type combination" operator for this scenario. It is actually hard for me to think of cases where the concepts of intersection or structural overloading/merging would be useful for these purposes (I mean, even if they do manage to model it correctly). Especially when it comes to function type conflicts, where not being able to deterministically predict the signature wouldn't be very useful. Anyway, it's true that in practice The issue with generic type parameters seems like a very technical one, that I personally don't completely understand. And not completely convinced it is a justified reason to determine major design decisions. So at this point I feel that without clear design goals and convincing use cases, any proposed operator would feel arbitrary to a degree. My original intention here was not to propose an alternative because it is ultra-useful for real-world object merging, but to show an alternative to an existing one that I thought was problematic (on many levels). As I've mentioned several times, I think most of the real-world usefulness here comes from the ability to apply ad-hoc multiple inheritance, not specifically the "conflict-resolution" feature. That's why I felt it was important to maintain a high (or even complete) level of backwards compatibility with it. |
Sorry, I am not following. Specifically, what are "ad hoc multiple inheritance" and "conflict resolution"? We are also using terms differently. When I say "object types", I am including what you are calling "function types" and "hybrid types". |
Ad-hoc multiple inheritance means it is possible to replicate the effect of inheritance through an operator: interface A {
prop1: number;
}
interface B {
prop2: string;
}
class Test {
// The return type of this method is an ad-hoc interface equivalent to
// interface B extends A { [original content of B] }
// which would equal { prop1: number, prop2: string }
func(arg: number): B extend A
} I used "object types" to refer to interfaces that contain only properties (and no function or construct signatures) Please ask if there more things I wrote that unclear. As an "outsider" it is not obvious for me to align with your terminology and completely understand the conventions used to discuss the language (of the sort that would happen in design meetings etc.) . |
"Conflict resolution" is a general way to describe any automatic method the compiler uses to deal with type conflicts between identically named properties - the two discussed methods were overloading (for properties with function types) and unions. I generalized it to something independent of theoretical or mathematical frameworks. |
Thanks for explaining. But seeing the explanation for ad hoc multiple inheritance makes me come full circle to the idea that intersection types already do what you want. I am once again not understanding what is lacking from intersection types even though it seems like you explained it many times. |
Anyway, now that 1.6 is finalized (I'm not sure if to Alpha, Beta, or Release, though) and intersection as well, all I can say is that it is unfortunate that the most common use case of intersection would now be replicating ad-hoc inheritance. It is sad to see people use the verb "intersect" to describe inheritance (AKA "newspeak"). It is sad to see TS team members perpetuate this over and over again. If there is no need to resolve identically named properties with incompatible types (i.e. "conflicts") then the operation would just be equivalent to inheritance (with or without errors on cycles, I don't see that as a huge problem for a structurally typed language). I strongly prefer it to be named that way, because that is exactly what it is. Theoretically it also may be a step back with serious implications about subtyping. My original proposal titled "Inheritance with overloading" described a hierarchical operator in the spirit of interface A {
func(arg: number): boolean;
}
interface B overloads A {
func(arg: string): number;
}
let a: A;
let b: B;
a = b;
// This assignment is valid since overloading does not have the
// problem of "widening" types as would be with unions.
b = a; // Error: incompatible types
// This assignment would fail because func has two overloads in B and only one in A. So it is possible to define a midway hierarchical operation that would overload on function redefinitions, but error on property redefinitions: interface A {
prop: number;
}
interface B overloads A {
prop: string;
} // Error: property "prop" is re-defined.
// This would be disallowed to preserve assignability to the super-interface A I'm just giving it as another example of the range of alternatives that could have been used (though it would not substitute the entire functionality of intersection, it would be sufficient in many cases). [This was written before your response, so I will continue in the next comment] |
Both Andres and you mentioned the requirement for using the operator with generic type parameters. I would first say that sounds like a constraint, not a design goal, and it is not clear to me why it should dictate major design decisions. I think that in order to understand it, I would need a detailed explanation of what it is and where it comes from. The original statement was like this: declare function combine<T, U>(obj1: T, obj2: U): T & U; What does this really imply? The most that I could understand so far is that it is not possible to know that types of |
Intersecting (or merging, combining, whatever you want to call it) generic type parameters is actually a main use case. It is what people wanted, and is the most generally useful. That way, people can define functions like combine that work on any two types. |
Intersection is not fundamentally about merging properties by the way. It is about simultaneously being two or more types at the same time. If the types are defined by their properties, then this does mean the properties get combined. But not every type is defined by properties (the most obvious example being union types). |
The language already has a concept of inheritance, and now intersection types. We would like to limit the complexity in this area in the time being. |
This was a discussion about a direct alternative and replacement for intersection types, and was suggested before it was finalized into the language. That is mentioned many times in the discussion. Since intersection types have been finalized, I agree it would not be wise to introduce a duplicate feature. I've mentioned that several times as well, including two and a half months ago, even before the code for intersections was committed into the development branch. |
So it is not possible neither to overload or subtract property from the interface? interface A {
x: number
a: string
}
interface B extends A {
a: undefined // will error Interface 'B' incorrectly extends interface 'A'.
}
// want `a` be forced undefined or optional
let b: B = {x: 1, a: undefined} // will error |
With advanced types you can use the "Omit" pattern to remove fields from an interface/type: type Diff<T extends string, U extends string> = ({[P in T]: P} & {[P in U]: never} & {[x: string]: never})[T];
type Omit<T, K extends keyof T> = {[P in Diff<keyof T, K>]: T[P]};
type Overwrite<T, U> = Pick<T, keyof Omit<T & U, keyof U>> & U;
interface A {
x: number;
y: number
}
type B = Omit<A, 'x'>; // {y: number} |
An alternative to
extends
that is mostly identical but with the following variation: instead of erroring when properties are re-declared with different types, it would resolve to the type unions instead (properties having a function type are already overloaded in inheritance, so there's no need to address them):An operator based on this variation of inheritance would have the property that for any two (object or function) interfaces
A
,B
,A overload B
(illustrative, not suggested, name) can never fail, even if the operands contain incompatible property types (note: this assumes cycles are ignored in the operator version). In contrast to intersection, it wouldn't even fail ifA
is an object type andB
is a function type, as hybrid object/function types are already allowed by standard inheritance.This an attempt to address the challenge presented by @ahejlsberg of having an inheritance-like operation that is guaranteed not to fail when applied to generic type parameters:
and thus may be used as a replacement to intersection that would be more consistent with the rest of the language (and inheritance logic in particular).
Even though I'm not a computer scientist or a language expert I tried to do my best for giving a basic direction (that is, at least a possible idea) on how to solve this. I'm not in a position to be able to see all the details, nuances or problems for integrating something like this into the language.
The text was updated successfully, but these errors were encountered: