[Re-Proposal] Type only Unions

This proposal is to restart Proposal: Union Type by frogcjn · Pull Request #404 · swiftlang/swift-evolution · GitHub, which is currently a few that do not have valid follow-up discussions in the forum, such as
[Proposal] Union Type
Multiple Types

Introduction

The generalized Union includes value unoin and type union, typescript supports both:

unoin for value
let value = 1 | 2 | "3"
switch (value) {
    case 1: break
    case 2: break
    case "3": break
    case 4.0 // error
}
unoin for type
let value: number | String
if (typeof value === 'number') {
    ...
}
// This if check can be replaced with `else` directly
if (typeof value === 'string') {
    ...
}

This proposal only suggests adding unoin for type, abbreviated as Type Union, to Swift. Type Union allow a variable to hold a value of multiple types, improving flexibility and type safety.

Motivation

A rewritten draft [Re-Proposal] Type only Unions - #70 by miku1958

In particular, current Swift 6 support Typed throws throws(AError), if a developer wants to throw more than one type of error at the same time, there is a lot of extra work to define.

enum ErrorFoo: Error {
    // ...
}
enum ErrorBar: Error {
    // ...
}

enum Errors: Error {
    case foo(ErrorFoo)
    case bar(ErrorBar)
}

func doSomething() throws(Errors) {
    // ...
}

do {
    try doSomething()
} catch .foo(let error) {
    // ...
} catch .bar(let error) {
    // ...
} catch {
    // requirement, otherwise the Error `thrown from here are not handled because the enclosing catch is not exhaustive` shows
}

Proposed Solution

Swift already has a (&) syntax for merging multiple protocols with an actual type

protocol NSViewBuilder {
    // ...
}
protocol NSViewDebuggable {
    // ...
}
let view: NSView & NSViewBuilder & NSViewDebuggable

To which adding (|), allowing a variable to be declared as one of several possible types.

typealias CodablePrimitiveValue = (String | Int64 | UInt64 | Bool | Double)?

let value: CodablePrimitiveValue

switch value {
case let value as String:
  print(value)
case let value as Int64:
  print(value)
case let value as Bool:
  print(value)
case let value as Double:
  print(value)
case let value as UInt64:
  print(value)
case .none
    print("null")
}
enum ErrorFoo: Error {
    // ...
}
enum ErrorBar: Error {
    // ...
}

func doSomething() throws(ErrorFoo | ErrorBar) {
    // ...
}

do {
    try doSomething()
} catch let error as ErrorFoo {
    // ...
} catch let error as ErrorBar {
    // ...
}

Detailed Design

  1. Syntax:

    • Use the (|) symbol to denote a Type Union.
    • Example:
      let value: Int | String
      
      throws(ErrorFoo | ErrorBar)
      
  2. Usage:

    • Assignment to a union-typed variable must match one of the union types.

    • Example:

      value = 42       // Allowed
      value = "hello"  // Also allowed
      value = 3.14     // Error: Type 'Double' does not match 'Int | String'
      
      value = .errorFromFoo     // Allowed
      value = .errorFromBar     // Also allowed
      value = .errorFromURL     // Error: Type 'errorFromURL' does not match 'ErrorFoo | ErrorBar'
      
  3. Type Inference:

    • Swift's type inference system should be able to deduce the type of union-typed variables within the context where they are used.
    • Functions and expressions and Typed throws should support union types seamlessly.
  4. Pattern Matching:

    • Extend Swift's pattern matching to handle Type Union.
    • Example:
      // ❌ Error: Switch must be exhaustive
      switch value {
      case let intValue as Int:
          print("Integer value: \(intValue)")
      }
      
      // ✅
      switch value {
      case let intValue as Int:
          print("Integer value: \(intValue)")
      case let stringValue as String:
          print("String value: \(stringValue)")
      }
      
      // ❌ Error: thrown from here are not handled because the enclosing catch is not exhaustive
      do {
          try doSomething()
      } catch let error as ErrorFoo {
          // ...
      }
      
      // ✅
      do {
          try doSomething()
      } catch let error as ErrorFoo {
          // ...
      } catch let error as ErrorBar {
          // ...
      } 
      
  5. Interoperability with Existing Types:

    • Type Union should integrate smoothly with existing types and collections.
    • Example:
      let mixedArray: [Int | String] = [1, "two", 3, "four"]
      

Impact on Existing Code

This feature does not affect existing code. It's purely additive and backward compatible.

Alternatives Considered

  • Enums with associated values: While they provide similar functionality, but they are less flexible and require more boilerplate.
  • Protocols: Although powerful, they require more complex type constraints and can be less straightforward for simple type union.
  • For Typed throws: throws(ErrorFoo, ErrorBar) seems like a nice syntax, but it's supposed to be implemented in the same way as a Type Union or Enum(Memory length is stored based on the type with the largest one), so why not extend it with full Type Union support?

Implementation

"existential type" is a more appropriate implementation, where the compiler can automatically integrate the usable method/properties/superclass/protocol of the type being unionized, which has the advantage of implementing something can access the common functions/properties like:

let view: NSTableView | NSCollectionView // both are subclass of NSView

// view can use NSView's content when unwrapped
view.isOpaque = false

This automation is also needed if we want typed throws can use it, so that the compiler can automatically figure out that the common part is a Swift.Error, and use the union type as Swift.Error.

let error: ErrorFoo | ErrorBar // both are Swift.Error

throw error

A rough compiler implementation detail: [Re-Proposal] Type only Unions - #43 by miku1958

Conclusion

Adding Type Union to Swift will simplify handling of variables that can take multiple types and improve code readability, maintainability, and type safety.

9 Likes

I think this has been brought up multiple times and was generally rejected.

I also do not like this proposal, it doesn’t work well with Swifts philosophy on types in general.

Additionally, I do not see a big use case where I would need just 2 completely different types without any underlying logic how to handle them differently which is perfectly supported in enums.

14 Likes

Typed throws are the best example of supporting this

The fact that it can be replaced by an enum is not a good reason to reject, async can be replaced by an asynchronous callback, and even more so, if all the syntax in Swift can be implemented in pure C, doesn't that mean Swift doesn't be needed? Not to mention that Swift has added a lot of requirements over the past few years that have been mentioned since Swift 3, so please don't say that xxx can be replaced.

3 Likes

Also I don't think & is "Swifts philosophy" either, ObjC already uses a Swift-like NSView<NSViewBuilder, NSViewDebuggable> in its compatibility paradigm, but in Swift the syntax becomes NSView & NSViewBuilder & NSViewDebuggable

Just to rehash the past ideas. In my naive non-compiler engineer view all we need would be this:

  • permit enum cases to begin with digits
  • orthogonal variadic pack expansion in order to project the generic type parameters in a dedicated associated enum case
enum OneOf<A, B, C, ...> { // `enum OneOf<each T>` 
  case 0(A)
  case 1(B)
  case 2(C)
  ...
}
  • convenient syntax sugar to make (A | B) mean OneOf<A, B>
  • allow room for future labels on generic type parameters with their expansion to the enum cases (similar to tuples) OneOf<foo: A, B> results to
enum OneOf<foo: A, B> {
  case 0(A)
  case 1(B)

  static func foo(_ a: A) -> Self { .0(a) }
}


let example_1: (foo: A | B) = .foo(someA)
let example_2: (foo: A | B) = .1(someB)
  • OneOf<String, String> is totally valid, because it will be either .0(someString) or .1(someString). All we did is provided some variadic super powers to enums with associated types. RHS of the assignment will not be implicitly propagated, hence let example_3: (foo: A | B) = someB should remain illegal.
  • let the stdlib vend this common type in a possibly back ported manner
5 Likes

The enum certainly solves the problem, but my proposal is to add a more efficient and concise syntax. As I replied earlier, all of swift's syntax can theoretically be implemented in C, so do I need to use C and give up Swift?

Also, to use OneOf for Typed throws you would need to implement it as Swift.Error, and then every similar place you would need to do something similar, which wouldn't be an elegant solution, and the compiler should do this automatically.

1 Like

Would this be implemented like an enum (where union instances reserve space inline for each of the union’s variants along with a discriminator) or like an existential (where the union’s contents are boxed and finding the active variant requires a dynamic type check)?

IMO having both kinds of union in the language would be useful. Existential-style unions would be good for things like typed throws, while enum-style unions would be better for most other tasks.

I think something like Either<Int, String, Double> would be the best syntax for enum-style unions — I don’t think they warrant a new syntax. Something like any AError | BError | CError would be the best syntax for existential-style unions.

1 Like

I think "existential" is a more appropriate implementation, where the compiler can automatically integrate the common type/protocols of the type being unionized, which has the advantage of implementing something can access the common functions/properties like:

let view: NSTableView | NSCollectionView // the common type is NSView

// view can use NSView's content when unwrapped
view.isOpaque = false

This automation is also needed if we want typed throws can use it, so that the compiler can automatically figure out that the common part is a Swift.Error, and use the union type as Swift.Error.

let error: ErrorFoo | ErrorBar // the common is Swift.Error

throw error
1 Like

Also we need to consider cases when united types may overlap, e.g. when at least one of the united types is an existential:

protocol P {}
protocol Q {}
struct S: P, Q {}
let x: any P | any Q = S()

With enum as backing representation this is ambiguous, and may lead to weird glitches in equality.

Also note that you totally can have an existential-like implementation with a buffer size computed based on sizes of united types. Then if all involved types are concrete types, every value will be stored inline.

2 Likes

Not sure what you mean here. You can define a simple conditional conformance over the variadic pack.

extension OneOf: Error where repeat each T: Error {} // done

Good catch, we definitely need to avoid the case you mentioned or Foo | Foo

What I mean is that for any case that requires an "x x x" protocol like throw, OneOf have to add an extension to it, and very generic ones like Error are fine, but if it's just a one-time use for a parameter, it has to add an extension, which is what the compiler should be doing

It’s telling that you didn’t include any examples of this in your initial post, because this is what separates a true union type from a discriminated (or tagged) union. It’s also where some of the most complicated questions live.

protocol P {
  associatedtype Assoc
  func getAssoc() -> Assoc
}

struct A<T> {
  public let wrapped: T
  func getAssoc() -> T { wrapped }
}

struct B {
  func getAssoc() -> Int { 42 }
}

func f<T>(x: A<T> | B) {
  let z = x.getAssoc() // what is the static type of `z`?
}
4 Likes

My point was that this should be supported, not avoided.

It may seem weird when used like this literally, but may happen as a result of substitution of generic arguments.

enum S<T, U> {
    typealias Either = T | U
}

var foo: S<Foo, Foo>.Either = Foo() // Should work

Also, if substitution of generic arguments produces Base | Derived would be nice to simplify it to just Base.

Still no idea what you mean. Typed throws with OneOf should be just fine when all types are errors themselves, then the entire type is also an error.

Everything that this proposal is going for is exactly what we've rejected before.

16 Likes

Also, the motivating example of using this for typed errors is something we've specifically and repeatedly recommended against as a pattern.

17 Likes

I understand why you can't name properties and cases numbers directly, but does anyone know the reason why we don't allow naming them as numbers by backticking the numbers?

I think what he's trying to say is that while this is fine for Error in particular, he doesn't want to add an extension to OneOf for every protocol he writes and then later uses as a constraint on an parameter taking multiple types.

Whereas his proposed "unions" would automatically conform to any protocol that all of the combined types conform to, because they'd have some sort of type/constraint unification pass.