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

proposal: spec: finite type set interface as union type #70752

Open
2 of 4 tasks
paskozdilar opened this issue Dec 10, 2024 · 29 comments
Open
2 of 4 tasks

proposal: spec: finite type set interface as union type #70752

paskozdilar opened this issue Dec 10, 2024 · 29 comments
Labels
LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Milestone

Comments

@paskozdilar
Copy link

paskozdilar commented Dec 10, 2024

Go Programming Experience

Intermediate

Other Languages Experience

C, C++, Python, TypeScript

Related Idea

  • Has this idea, or one like it, been proposed before?
  • Does this affect error handling?
  • Is this about generics?
  • Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit

Has this idea, or one like it, been proposed before?

  1. There have been multiple proposals on this topic, with different kinds of syntax:

#54685
#19412

Most of which suggest introducing new syntax, such as type MyUnion = Type1 | Type2 | ...

  1. This proposal differs by simply re-using already existing syntax in a new context.

Does this affect error handling?

No.

Is this about generics?

No.

Although this proposal builds on the type set concept introduced in generics, it is not about generics itself.

Proposal

The introduction of generics in Go has introduced the concept of type constraints, which have syntax similar to interfaces, but are not interchangable:

type TypeConstraint interface {
    ~int | string
}

var v TypeConstraint // error: cannot use type TypeConstraint outside a type constraint

Previous proposals of re-using type constraints for union types have been rejected, the reason being the inherent inability of a compiler to produce exhaustive type search when the number of types that fit the constraint is infinite:

type MyInt1 int
type MyInt2 int
// ...
// type MyIntAlephNull int

type TypeConstraint interface {
    ~int | string
}

This proposal suggests permitting a restricted set of type constraints to be usable as Union types.
The "restricted set of type constraints" here is defined as a type constraint which only contains a union of "exact types" - meaning builtin types, structs, pointers, functions and named types whose underlying type is a builtin type, struct, pointer or function - and other union interfaces. Method-set interface types are not allowed. Structs that embed interface types are allowed as a workaround.
I.e. ~Type syntax is not allowed and method sets are not allowed.

This restriction covers the basic use case of unions - exhaustive type switch.

Since type union would essencially be an interface, its default value would be nil, and nil could be checked in the switch statement, just like with regular interfaces.

Example, using go/ast as inspiration:

type Keyword struct {}
type AssignStmt struct {}
type BadExpr struct {}
// ...

type Node interface {
    Keyword | AssignStmt | BadExpr // | ...
}

// no default:
var n node
switch n.(type) {
    case nil: // ...
    case Keyword: // ...
    case AssignStmt: // ...
    // go vet: type switch of type union not exhaustive
}

switch n.(type) {
    case nil: // ...
    case Keyword: // ...
    case AssignStmt: // ...
    default: // ok - default case covered
}

Alternatively, default case could be forbidden completely for type unions. That would be the conservative thing to do - as we can always allow default post-hoc, but we couldn't forbid it once it's already in the language.

EDIT: Enforcing exhaustive type switch by compiler is incompatible with Go's approach towards gradual code repair. Therefore, checking of exhaustive type switches of union types should be left to other tools.


A union of two unions would contain union of all their elements:

type Intish interface {
    int | uint
}

type Floatish interface {
    float32 | float64
}

// These two interfaces are equivalent:
type Numberish1 interface {
    int | uint | float32 | float64
}

type Numberish2 interface {
    Intish | Floatish
}

Open question is whether to allow type unions as cases in type switches. Allowing them could cause ambiguities:

var v Numberish = 42
switch v.(type) {
    // which case gets executed?
    case Intish:
    case int:
}

We could possibly deal with this in a similar manner that net/http deals with routes: by making the most specific type set win - e.g. int over Intish, float64 over Floatish - and disallowing type sets that are equally specific.

I personally would prefer to simply disallow type union interfaces in case statements whatsoever, to avoid having to deal with this set theory complexity.


Advantage of this approach is the simplicity of syntax and its similarity to already-existing concepts of interfaces and type constraints.

Disadvantage of this approach would be similar of the disadvantages of generics - separation of "interface" syntax into yet another form - type union.
So the type Foo interface syntax could mean three different things, depending on its definition:

  1. interface
  2. type constraint
  3. union type

Currently, interfaces are subset of type constraints.
In this proposal, union types would be also subset of type constraints, disjoint from interfaces.

Type unions would also play well with type parameters:

type ArrayOrSet[T comparable] interface {
    []T | map[T]bool
]

Since type parameters must be specified on instantiation, the particular instance of ArrayOfSet[T] type union would still be a finite set of exact types.

Language Spec Changes

We would need to define a "type union" concept in a similar manner to type constraints, e.g.:

A type union is an interface type whose type set is strictly finite.
This definition implies that types in the type union definition cannot contain method sets (as there could be infinite number of exact types that implement the interface) or an underlying type ~T (as there could be inifinite number of exact types whose underlying type is T).
This restricts type unions to just unions of builtin types, structures, pointers, functions and other type unions interfaces.

A type union interface containing type union interfaces contains all exact types contained inside all of contained type unions.

A type union differs from an interface in that the switch statement of a type union must be exhaustive.
A switch statement of a union type that is not exhaustive will result in a compilation error.
Type switch of union type interface may contain only exact types contained in the union.

In all other contexts, type union behaves like interface any. E.g. comparison will panic if the underlying types are not comparable.

Informal Change

Type unions are a special kind of interfaces - interfaces which only specify a union of exact types:

type MyUnion interface { int | string | float64 }

They are special because compiler (e.g.) go vet enforces that a switch statement must check warns if a type switch statement has not checked all possible cases, including nil:

var u MyUnion
switch u.(type) {
    case nil: // ...
    case int: // ...
    case string: // ...
    // go vet: type switch of type union not exhaustive 
}

This is useful when in business domain code where we want to make sure we've covered all possible cases in many different parts of code when we add a new type to the union.

Is this change backward compatible?

Yes.

Since embedding exact types in ordinary interfaces is not allowed, union types would be completely disjoint from ordinary interfaces.
Since union types would be a subset of type constraints, they could be used as type parameters.

The only difference would be the ability to use union types in the way ordinary interfaces are used - with the addition of compiler enforcing tools being able to check exhaustive type switch for them.

Please see sections above for examples.

Orthogonality: How does this change interact or overlap with existing features?

I believe it fits well within the current path Go is evolving, ever since generics were introduced, together with type sets being the underlying concepts of both interfaces and type constraints. Type unions are a special case of type sets - finite type sets.

Would this change make Go easier or harder to learn, and why?

It would make Go harder to learn, as there would be yet another context of interface syntax to learn.

But I believe people who understand type set concept - required for also understanding generics - will easily grasp the concept of type unions.

Cost Description

Compiler would need to be made aware of union types and would need to enforce exhaustive checks.

Exported type unions could break dependent code when extended.
Although regular interfaces may also break dependent code when extended, this only happens if user uses an exported interface in combination with their own type (or third party type) that implements the interface.

In a nutshell - any switch statement on exported type unions would break when type union is extended.
Although one might argue that this is exactly the point of union types.

Additionally, this change will increase the inconsistency between treating interface types and type parameters:

type Integer interface {
    int32 | int64
}

func Add1(a, b Integer) Integer {
    return a + b; // invalid operation: operator + not defined on a (variable of type Integer)
}

func Add2[T Integer](a, b T) T {
    return a + b; // ok
}

Changes to Go ToolChain

vet, gopls

Performance Costs

Compile time cost could be increased depending on implementation. Run time cost would be same as with regular interfaces.

Prototype

Union types could be implemented as ordinary interfaces, with the addition of checking that the switch statements are exhaustive contain only exact types. The underlying implementation could be exactly the same as interface implementation.

@paskozdilar paskozdilar added LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal labels Dec 10, 2024
@gopherbot gopherbot added this to the Proposal milestone Dec 10, 2024
@paskozdilar paskozdilar changed the title proposal: spec: allow restricted type constraint to be used as sum type proposal: spec: allow restricted type constraint to be used as union type Dec 10, 2024
@paskozdilar paskozdilar changed the title proposal: spec: allow restricted type constraint to be used as union type proposal: spec: finite type set interface as union type Dec 10, 2024
@seankhliao
Copy link
Member

I think the final comments on #54685 apply here. This proposal describes a change, but lacks strong reasoning on why this proposal should be accepted over the other ones, why the choices here are reasonable (nil default, type switch exhaustiveness), and what this proposal enables, taking into account the comments on the previous proposals.

@paskozdilar
Copy link
Author

paskozdilar commented Dec 10, 2024

why the choices here are reasonable (nil default, type switch exhaustiveness)

The "nil default" choice is reasonable because type union would still be an interface, so it makes sense it behaves like one in all scenarios, except in switch type checks - which seem to be the biggest motivation behind union types entirely. In any other context, union types don't give much benefit over interfaces.

Any decision to leave out nil as possible union value leaves out a question of zero value - which would either make it behave differently from interfaces altogether, or create implicit behavior that is hard to predict.

In conclusion, "nil default" is the most similar with current way the interfaces work, and make the union type addition simpler - and exhaustive type check is the main benefit type union would have over regular interfaces.


Consider the following proto definition:

syntax = "proto3";
package example;
option go_package = "./example";

message Request {
  oneof data {
    bytes bdata = 1;
    string sdata = 2;
  }
}

When compiled to Go code, data member of the Request struct looks like this (details omitted for clarity):

type Request struct {
	// Types that are assignable to Data:
	//
	//	*Request_Bdata
	//	*Request_Sdata
	Data isRequest_Data `protobuf_oneof:"data"`
}

type isRequest_Data interface {
	isRequest_Data()
}

type Request_Bdata struct {
	Bdata []byte `protobuf:"bytes,1,opt,name=bdata,proto3,oneof"`
}

type Request_Sdata struct {
	Sdata string `protobuf:"bytes,2,opt,name=sdata,proto3,oneof"`
}

func (*Request_Bdata) isRequest_Data() {}

func (*Request_Sdata) isRequest_Data() {}

The Go gRPC implementation uses an ad-hoc union type by defining an interface with an empty isRequest_Data method.

This proposal will allow a straightforward definition of Data:

type Request struct {
	// Types that are assignable to Data:
	//
	//	*Request_Bdata
	//	*Request_Sdata
	Data isRequest_Data `protobuf_oneof:"data"`
}

type isRequest_Data interface {
	*Request_BData | *Request_Sdata
}

// ...

In case proto is extended with additional oneof, we will get compiler error wherever we don't handle the new case:

message Request {
  oneof data {
    bytes bdata = 1;
    string sdata = 2;
    int32 idata = 3;
  }
}
var r &example.Request
switch r.Data.(type) {
  case *nil:
  case *Request_Bdata:
  case *Request_Sdata:
  // error: type switch of type union not exhaustive
}

In the currently-generated Go code, there is no way to verify that we've handled all cases. The exhaustive switch type check is the very thing that distinguishes union types from the interfaces.


I've spent significant time reading the other proposals. In fact, comment #54685 (comment) is the one that inspired me to try to come up with the simplest change to Go which would introduce the exhaustive type-check mechanism. I'd be happy to elaborate more on why this proposal should be accepted, but my main reason is that it is more simple, while intruducing the very functionality that isn't included in the existing interface implementation - the ability for compiler to enforce that we've exhaustively check all cases. If we don't want that behavior, we can use ordinary interface (or default: statement, as mentioned in the original spec post).

Any feedback on why this proposal is worse than others would be appreciated, but in the meantime I will actively compare the proposal with others and write a deeper analysis in a few days.

@paskozdilar
Copy link
Author

I hope this Venn Diagram of my proposal will suggest the simplicity of it:

Untitled Diagram drawio

@apparentlymart
Copy link

apparentlymart commented Dec 10, 2024

I have in the past been an advocate for the use-case of "closed interfaces" or similar -- across various different proposals -- so my default outlook on this is positive. 😀


It seems like an implication of the details of this proposal is that a value of a "type union" interface would support no operations except what would be supported for any: equality tests that might panic if the dynamic type is noncomparable, and type assertions/switches.

For example, a value of type interface { int64 | int32 } would not support the + operator even though a type parameter constrained to that would support it.

type Integer interface {
    int64 | int32
}

func example1(a, b Integer) Integer {
    return a + b // invalid
}

func example2[T Integer](a, b T) T {
    return a + b // valid
}

Does that match what you were intending?

If so: while I don't think this is disqualifying, it does represent an additional inconsistency in the treatment of type parameters vs. interface values, which seems like a cost worth capturing in the relevant section of the proposal.


The "equality tests that might panic if the dynamic type is noncomparable" is probably also a detail worth thinking about and specifying directly. I assumed it would follow the rules for any, but of course there is another alternative:

We could say that a "type union" interface type is comparable only if all of the types in the union are comparable, so that == with a value of such a type can never panic.

Of course, that adds another element of risk to the "exported type unions could break dependent code when extended": extending a type union that was previously all comparable types with one that isn't comparable would break not only exhaustive type switches but also any existing equality tests.

Again, I don't think it's necessarily disqualifying but it is still a cost. Overall it's probably simpler to just keep the existing rules that interface-typed values are always comparable but might panic at runtime, for consistency's sake.

@timothy-king
Copy link
Contributor

The core type proposal #70128 has significant implications for this proposal. It is currently on hold. If it does go forward at some point, IMO it would significantly shift what operators would be natural for to allow on interface{ int | int8 }. It would push towards supporting '+' on such interfaces for example. Then one needs to decide about dynamic behavior of mismatched types and/or nil.

@paskozdilar
Copy link
Author

Does that match what you were intending?

Yes, all you've written is completely correct.

If so: while I don't think this is disqualifying, it does represent an additional inconsistency in the treatment of type parameters vs. interface values, which seems like a cost worth capturing in the relevant section of the proposal.

I've added the example to the costs.

The "equality tests that might panic if the dynamic type is noncomparable" is probably also a detail worth thinking about and specifying directly. I assumed it would follow the rules for any, but of course there is another alternative:

We could say that a "type union" interface type is comparable only if all of the types in the union are comparable, so that == with a value of such a type can never panic.

While I see the reasoning behind, I personally think it would be more confusing 1) being inconsistent with interface values 2) being sometimes comparable, sometimes not, depending on the types in the union.

Overall it's probably simpler to just keep the existing rules that interface-typed values are always comparable but might panic at runtime, for consistency's sake.

Agreed.

Additionally, to build onto the Integer example, Integer(int32(42)) == Integer(int64(42)) shall evaluate to false, in similar manner with any.

@earthboundkid
Copy link
Contributor

What happens if a generic function tries to accept different union types?

type Intish interface { int | uint }

func Add[N Intish](a, b N) N

var a, b Intish = int(1), uint(2)

Add(a, b) // This can't work, right?


// But then how do I write a generic function 
// that does accept Intish interface values among other types?
type Floatish interface { float32, float64 }

func StoreNumber[ what goes here to accept Intish or Floatish? ](a Numeric) error

@mateusz834
Copy link
Member

This is just a more restricted version of #57644?

@atdiar
Copy link

atdiar commented Dec 10, 2024

@timothy-king that's what the initial thought was at some point but I think that if we consider operators as being methods +(T, T) T
They are simply just different since each signature is different.
In that case, we won't have operators on interfaces (unless we have the typeset reduced to a singleton that is, in that you're right that it could be affected; it's the case for a type parameter for instance)

(Also why it should be better to make sure that such union values with untyped nil inside (i.e. empty) are not used, only declared. Because untyped nil has no such operator and does not belong to the typeset (well, it's "untyped" nil, it has no type I guess)

Also, long-term thinking, not even sure that I would personally allow operators on unions with a singleton typeset.
Reason being that if someone decides that it could be good some day to implement refinement types, e.g. interface{ int where int < 10}, two such values would not be addable.

Maybe I've read too fast but this proposal seems like a dup of #57644?

@apparentlymart
Copy link

I guess we'd also need to figure out how this new kind of interface value participates in reflect.Type.

Some initial thoughts on that:

  • Kind returns Interface.
  • NumMethod on these types always returns zero, Method always panics, and MethodByName always returns false from its second argument.
  • Implements with this new variant of interface as an argument returns true if the receiver is one of the types named in the type union, or false otherwise.
  • AssignableTo/ConvertibleTo follow the same rules as the corresponding language features as described in the proposal.
  • Most of the other methods are irrelevant for these types.

Writing this out did make me realize that this proposal seems to create a new sort of interface type that acts like any/interface{} if you ask about its methods, but acts a bit like an interface with an unnameable method if you ask about its assignability, and acts unlike anything else if you ask if some other type implements it.

I wonder if there's any existing code out there which is making assumptions about how interface types behave under reflect that would have those assumptions violated by this new variant of interface type. 🤔

@ianlancetaylor
Copy link
Member

@timothy-king

what operators would be natural for to allow on interface{ int | int8 }. It would push towards supporting '+' on such interfaces for example.

I don't agree. If you have two different values of that interface type, one might be int and the other might be int8. We don't want to permit adding those two value together. This is difference from the use of such an interface as a type constraint: two values of type T constrained by interface { int | int8 } must be the same type.

@ianlancetaylor
Copy link
Member

I'm inclined to close this as a dup of #57644. Any argument against that?

@paskozdilar
Copy link
Author

paskozdilar commented Dec 11, 2024

@mateusz834 @atdiar In a nutshell, this is a very restricted and simplified version of #57644.
I've started from the goal of having exhaustive type-checking, and realized that having a finite type set is sufficient to achieve this.
The design went from there on.

@earthboundkid That is certainly a good question.

Regular (method-set) interface values always fulfill themselves-as-type-constraints. It would make sense for that to be the case for union types too.
But that would cause further problems:

type Intish interface { int | uint }
type Floatish interface { float32 | float64 }
func IncrementIntish(v Intish) Intish {
    return v + Intish(1) // error
}
func IncrementNumber[T Intish | Floatish](v T) T {
    return v + T(1) // should this be allowed? can T be Intish interface?
}

So union types cannot fulfill themselves-as-type-constraints.

Type constraints were implemented when the concept of type-unions-as-values did not exist, so they made an assumption that the underlying type of the value will always be exactly one of the specified constraint types. We cannot break that assumption.

I think the only reasonable option would be, if we wanted to get either Intish or Floatish, to allow specifying union types as part of the union type definition, e.g. type Numberish interface { Intish | Floatish } (which kind of converges into #57644 itself...). That would imply that we allow any type union that is a subset of the set {int, uint, float32, float64} - but considering that accepting either Intish or Floatish would theoretically be the same as accepting Intish | Floatish, that makes sense:

func DoSomething(v interface { Intish | Floatish }) {}

var v1 interface {int, float32}
DoSomething(v1) // ok

var v2 interface {uint, float64}
DoSomething(v2) // ok

That would also mean we need a type switch for the Intish type (which would be equivalent to case v.(int | uint).


@ianlancetaylor
The main difference I see, between #57644 and this proposal, is that this proposal strictly separates the method-set-interfaces from the union-type-interfaces. Method-set-interfaces cannot embed union-type-interfaces, and vice versa. And they serve two different purposes:

  1. Method-set-interfaces serve as an abstraction over an inifinite number of types on which we can call methods
  2. Union-type-interfaces serve as an abstraction over a finite set of types which we can do an exhaustive type switch on (enforced either by compiler or tooling).

If everything said here was also discussed there, feel free to close this one.

@earthboundkid
Copy link
Contributor

@earthboundkid That is certainly a good question.

Regular (method-set) interface values always fulfill themselves-as-type-constraints. It would make sense for that to be the case for union types too. But that would cause further problems:

Lol, I'm a philosophy PhD, and we have literally recreated from first principles the problem of whether the Platonic forms are self-describing: Is Goodness good, is Twoness two, etc. Twenty five hundred years later, it's still a hard problem!

@atdiar
Copy link

atdiar commented Dec 11, 2024

@paskozdilar
I think that it might be a bit too limiting.
Let me present you with a real use-case.

type Value2 interface{
    string | bool | float64 | []Value | map[string]Value | Value
} 

type Value interface{
    discriminant() // == String | Bool | Object | List
} 

f(v Value) discriminant() {)

type String string
func(s String) discriminant() {}

... (same for Bool, Object, List, they implement the discriminant method)

This is a basic representation of a use-case I have. Currently I can only use Value which doesn't allow basic types in the interface (string, bool, float64 etc) and therefore requires a conversion. It makes the API a bit heavy.

Ideally, I would like to migrate the code in a backward-compatible way by using Value2.

That would make the typeset of Value2 virtually infinite.

@timothy-king
Copy link
Contributor

@ianlancetaylor Operator + was kinda an unfortunate choice on my part and opened too large of a can of worms. My broader point though is that if core types are eliminated, that does remove hurdles for supporting more operators on interface unions. interface{ [128]int | [256]int } values could support indexing, and that seems kinda useful.

@ianlancetaylor
Copy link
Member

As others have alluded to, I don't think exhaustive type checking is a good fit for Go. In Go we aim for strict backward compatibility. If we support exhaustive checks for type switches on union types, then adding any type to a union type is an incompatible API change, because it will break code that uses that type. That's going to be difficult to work with. In the compatibility doc we were careful to ensure that adding fields to structs or interfaces to methods did not cause an incompatible API change. That is because Go is designed for programming at scale, and one aspect of programming at scale is that a change in one part of a large program should not cause unexpected breakage in another part of that program.

@paskozdilar
Copy link
Author

paskozdilar commented Dec 11, 2024

@atdiar
Unfortunately, adding method-set-interfaces to union types would cause ambiguities:

type Foobar struct{}
func (*Foobar) Foo() {}
func (*Foobar) Bar() {}

type Fooer interface {
    Foo()
}

type Barrer interface {
    Bar()
}

type MyUnion interface {
    Fooer | Barrer 
}

var v MyUnion = &Foobar{}
// which one of the cases above get executed?
switch v.(type) {
    case Fooer:
    case Barrer:
}

Disallowing method-set-interfaces is the only way I could think of that would make union-type-interfaces consistent.

@ianlancetaylor
That's fair.
My reasoning was, since adding new methods to the method-set-interface already breaks user code that uses that interface with third-party (or their own) types, it would be consistent to have the same behavior with union-type-sets. Example:

// exported.go
type Foobar interface{
    Foo()
    Bar() // added after code was already used in usercode.go
}

// usercode.go
type UserType struct{}
func (*UserType) Foo() {}
func DoSomething(f exported.Foobar) {}
func DoSomethingWithUserType(v UserType) {
    DoSomething(v) // UserType does not implement Foobar (missing method Bar)
}

But I can see why we would want to avoid opening further doors towards breaking user code.
It would also be acceptable for this proposal allow non-exhaustive type checks in the language itself, as long as it's possible to implement such checks in other tools (sufficient condition being that union-type-sets are restricted to a finite set of non-method-set-interface types).

I believe some method of achieving something-like-exhaustive-type-checking is worth having in Go. Two real-life examples come to mind immediately: ast.Node and gRPC message types.

@atdiar
Copy link

atdiar commented Dec 11, 2024

@paskozdilar the solution is to consider that there is a missing case that the compiler needs you to add, namely:

interface{
    Fooer
    Barrer
} 

Meaning that each union term and each case must be totally discriminating.

@paskozdilar
Copy link
Author

paskozdilar commented Dec 11, 2024

@atdiar
That would either cause 1) a combinatorial explosion of possible cases that need to be covered (if compiler forces you to cover all cases), or 2) surprising behavior (if compiler doesn't force you to cover all cases).

Example 1)

// compiler enforces exhaustive type switch
type Fooer interface { Foo() }
type Barrer interface { Bar() }
type Bazzer interface { Baz() }
type Quxxer interface { Qux() }
type Quuxxer interface { Quux() }

type MyUnion interface {
    Fooer | Barrer | Bazzer | Quxxer | Quuxxer
}

var v MyUnion
case v.(type) {
    // There are now 32 cases that need to be covered.
    // If we add another interface to the union, there will be 64. Then 128. And so on.
}

Example 1)

// compiler doesn't enforce exhaustive type switch
type Foobar struct{}
func (*Foobar) Foo() {}
func (*Foobar) Bar() {} // added later

type Fooer interface { Foo() }
type Barrer interface { Bar() }

type MyUnion interface {
    Fooer | Barrer
}

var v MyUnion = &Foobar{}
case v.(type) {
    case Fooer:
        // this code will not execute after Bar() method is added to Foobar, causing silent bugs
    case Barrer:
}

Neither case is truly acceptable to me.
I've put a lot of thought into this, and I haven't found a single way to make method-set-interfaces fit inside union-type-interface elegantly.

@atdiar
Copy link

atdiar commented Dec 11, 2024

@paskozdilar yes. This is a feature and not a bug. How often are you going to use overlapping cases in reality?

Even if you were to use code generation, if you have to write such unions then something is wrong.

@paskozdilar
Copy link
Author

paskozdilar commented Dec 11, 2024

I respectfully disagree.
Having a feature that forces user to write an exponential number of cases, even if in an edge case, is simply bad design, IMHO.

If you really want to have interfaces in a union, in context of this proposal, you can wrap them in a struct:

type Value interface { /* ... */ }
type ValueWrapper struct {
    Value Value
}

type MyUnion interface {
    int | string | ValueWrapper
}

This will work similarly, while avoiding all the complexity of having interfaces directly in the type union.

@atdiar
Copy link

atdiar commented Dec 11, 2024

@paskozdilar bad design would be to use a union of that many overlapping single method interfaces anyway.
That's very unlikely to be a common case.
This is a synthetic example you showed but I am curious to see who would do that in reality.
In which case, if being exhaustive is too strenuous, one could probably define a common supertype interface or even use any at this point.

Using a ValueWrapper defeats the purpose of simplifying APIs and backward compatibility if a conversion is still needed.

@paskozdilar
Copy link
Author

Nevertheless, the very foundation of this proposal is union-type-interfaces not being able to embed method-set-interfaces.
The whole design relies on that foundation.

If you're interested in discussing an alternative design, it would be more appropriate to create a new proposal for that.

@atdiar
Copy link

atdiar commented Dec 11, 2024

There is already a proposal that could eventually fit with the real use case I've presented above.

My intent is simply to provide you with use cases that were obviously in your blind spot. That's all.

Thanks for the discussion.

@leighmcculloch
Copy link
Contributor

A union of two unions would contain union of all their elements:

type Intish interface {
    int | uint
}

type Floatish interface {
    float32 | float64
}

// These two interfaces are equivalent:
type Numberish1 interface {
    int | uint | float32 | float64
}

type Numberish2 interface {
    Intish | Floatish
}

A flattening of unions when included in another union, is pretty surprising and not what I'd expect. In the above I would expect Numberish2 to be a union of two types only, where to get at the sub-variants, you'd first have to go through the top. Also, if Intish and Floatish shared a type, then those sub-variants would be unique via the parents.

switch v := numberish2.(type) {
    case Intish:
        switch v {
            case int: ...
            case uint: ...
        }
    case Floatish:
        switch v {
            case float32: ...
            case float64: ...
        }
}

@zephyrtronium
Copy link
Contributor

A flattening of unions when included in another union, is pretty surprising and not what I'd expect.

This is a consequence of reusing interfaces to express unions: an interface value cannot dynamically contain another interface value. Granted, I would expect that nested type switches like you mean in your example should still work under this proposal (or #57644), but the cases in the outer switch would be interface-to-interface conversions.

@paskozdilar
Copy link
Author

paskozdilar commented Dec 12, 2024

@leighmcculloch

A flattening of unions when included in another union, is pretty surprising and not what I'd expect.

I sort of agree.
But if we allow type unions to be treated as "exact types" in type unions, that would cause more complications:

type Union1 interface {
	int | uint
}

type Union2 interface {
	int | float32
}

type Union3 interface {
	Union1 | Union2
}

var v Union3 = 42

switch v.(type) {
    // which case gets executed here?
    case Union1:
    case Union2:
}

Any order we choose will make type unions not commutative - i.e. Union1 | Union2 would not be the same as Union2 | Union1.

In practice, we could use tools like gopls to see underlying types of a complex union type interface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Projects
None yet
Development

No branches or pull requests