Skip to content

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

Closed as not planned
@paskozdilar

Description

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.

The current proposal states:

A type parameter or a generic type may be used as a type in a case. If upon instantiation that type turns out to duplicate another entry in the switch, the first matching case is chosen.

It would be consistent to define same behavior here. Each case contains a type union, and the first one wins.


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.

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions