[Pitch] Enum Composition

It seems this has almost been discussed here and here but never with what I would consider to be an intuitive, Swift-esque syntax.

Essentially, I reckon it would be beneficial to be able to create a composed enum using & in the same way protocol composition is currently functioning. For example:

enum DatasourceError: Error {
    case http(Int), unknown(Error)
}

enum ManagerError: Error {
    case invalidData, unknown(Error)
}

// Usage
func handle(result: Result<Void, DatasourceError & ManagerError>) { // Synthesised composition using `&`
    guard case let .failure(error) = result else { return }

    switch result {
    case let .http(code): 
        handle(httpCode: code)
    case .invalidData: 
        handleInvalidData()
    case let .unknown(error): // DatasourceError.unknown and ManagerError.unknown are both handled here
        handle(genericError: error)
    } 
}

// Or explicitly
typealias ExtendedManagerError = ManagerError & DatasourceError

Currently the above is achievable by explicitly defining a case for each sub-enum that must be handled - this results in a bit of a messy switch statement and doesn't offer true composition flexibility.

Rules around usage should mirror protocol composition's rules: that is, cases with the same name (and associated values) are merged together into a single case.

Composable enums could also be handy if we get typed throws in the future: if only enum errors are thrown the compiler could implicitly compose an error enum based on the call stack.

Considerations:

  • If the enums implement a function or computed property with the same signature, the composed type may need to discard it.. not great (not terrible) but I'm sure someone else has a better idea?
  • Similarly, would any function on the enum need to be discarded or converted into an option? Maybe the composed type must be cast as a concrete type before calling functions on it?
  • If the enums have a case with the same name but different associated values... maybe then composition would not be possible? This may be similar to how protocol composition with two associated types of the same name yet different constraints works (well, the composition itself works but the composed type can never be implemented). Alternatively, maybe the user could be forced to prefix the check with the explicit type that the case belongs to?
  • If the enums conform to RawRepresentable, their RawValue types must match (a special case related to the first and second points above perhaps?)
11 Likes

I like it. However, given that an enum can have one of several values, and that the composition still could only have one of the combined values, maybe DatasourceError | ManagerError is a better spelling?

Also, could this be implemented using a type, maybe called Either, which could get compiler endowed syntactic sugar similar to how Optional is just a simple type with some ergonomic syntax. A | B could be sugar for Either<A, B> and it could compose, and switches over an Either type could automatically wrap your case labels in .left and .right cases accordingly?

3 Likes

Would need some way to constrain the generic values of Either to be only Enums so that you can't do a Either<Int, Float>.

I feel the A | B is the correct syntax, possibly if there are multiple cases that collide swift would require you to namespace them.

enum A {
    case progress(Int)
    case done
    case error
}

enum B {
    case progress(Float)
    case error
    case starting
}

typealias AB = A | B

let state: AB = ...

switch state {
    case .error: 
        ....
    case A.progress(let value):
       ...
    case B.progress(let value):
      ...
...
}

Or a type hint could be used that would allow these to fold case .progress(let value: SIMDScalar) that would match both cases.

5 Likes

Actually, I kinda like that possibility. It would fill the missing gap:

Nominal Anonymous
Product struct { ... } (A, B)
Sum enum { ... } `(A

There is currently no way to model anonymous sum types in Swift.

5 Likes

You could switch over (Int | Float) like so:

let number: (Int | Float) = ...

// this would create an enum like so
// enum Either<Int, Float> {
//    case 0(Int)
//    case 1(Float)
// }

switch number {
case 3 as Int: // interpreted as case .0(3)
case 3.0: // interpreted as case .1(3.0)
default: // ...
}

One could also create an anonymous type with labels:

let value: (foo: Foo | bar: Bar) = ...

// this would create an enum like so
// enum Either<Foo, Bar> {
//    case foo(Foo)
//    case bar(Bar)
// }

This is pretty nice actually - and I suppose if all of the types had a common conformance then the synthesized Either would have it too (so that we can still pass around Errors etc as in the original example). And, of course, if they were both enums then this should still work:

Huh... (A | B) is really interesting!

It's like a composable enum at the point of declaration with ~no additional code overhead.

Just as a way to easily wrap multiple Types I really like it. I could also see it being extended to allow non-unwrapped usage of APIs the Types have in common.

e.g. if something might give you a Set or an Array:

func process(items: Set<Int> | Array<Int>) -> Int {
    items.reduce(0, +)
}

This allows a kind of generic programming without needing to figure out what protocol the Types have in common that contains reduce (which can be an obtuse process and is unfriendly to new users)

Pretty cool!

1 Like

I'm pretty sure all these things have already been discussed

1 Like

This is a commonly rejected change:

  • Disjunctions (logical ORs) in type constraints: These include anonymous union-like types (e.g. (Int | String) for a type that can be inhabited by either an integer or a string). "[This type of constraint is] something that the type system cannot and should not support."

I thought that the rejected change was "anonymous union" and not "anonymous sum" (which I believe it's what is being discussed here)

1 Like

It's really a very powerful composition...
Take (T | Void) as example:
It's a type that is either T or nothing, with some interesting features:

  • Every function that accepts (T | Void) would also accept T
  • Given a function f() throws -> (T | Void) and a transformation try<R>(_ block: @autoclosure () throws -> R) -> (R | Void), the return type of try(f()) would be (T | Void)

This might look a little abstract, but it's more or less an Optional<T> — and those two features are special cases which had to be backed into Swift, because they don't come for free with enums.
It should also be possible to have optional closures that do not escape, and maybe even opaque return types which are optional (you can neither have func f() -> some T?, nor func g(block: Optional<(A) -> B>).
Alas, as you see above, it has been decided that Swift should have (A & B), but not (A | B); I still would really like to know what motivated this ruling… (bet it is type checking performance; when there is a nice thing we cannot have, it's always the type checker ;-)

This pattern gets used a lot in Typescript. It can definitely be useful, but it also quite often leads to confusing signatures and awkward pattern matching when a distinct type or enum would work better.

I'd still be interested in why the wording is so final in @xwu's link.

2 Likes

This doesn’t appear to be the commonly rejected anonymous union. Semantically, this would just be a new enum that has all the cases of the LHS and all of the cases of the RHS.

enum A { case a; case b }
enum B { case c; case d }
func foo() -> A | B { ... }
switch foo() {
    case .a:
    case .b:
    case .c:
    case .d:
}

I think this would be quite useful, but there’s a few edge cases to work out:

  • conversion to/from LHS and RHS values
  • assignment from and LHS or RHS value?
  • how to handle cases with the same name, and maybe other aliasing issues

There's also other edge cases:

  • indirect enums being composed with non-indirect enums
  • overload resolution of methods and properties on enums
  • self accesses in methods and properties on enums
  • resolution of conflicting raw representations
  • resolution of case's with associated values of related types

I’d absolutely love to have some way to combine enumerations. However, type ambiguity in Swift is a non-starter, and should remain that way.

Would it be possible to add enum-specific protocols that can require certain cases, then use that protocol as an existential? Switching over such an instance would require a default case, obviously, but combined with protocol composition that could accomplish this.

If we want to find an implementable solution I think we strive for parity with anonymous product types, instead of creating an anonymous sum type with implicit casting (i.e. func h(_: Int | Float); h(1.0) / /choose Float) and other "fancy" features.

I think this post from John outlines some key considerations:

All in all, I think that a more grounded approach, such as the following could work in current Swift:

Syntax:

// === Declaration-----------------------------------

typealias Content = Int | String 

typealias Action = receive: Image | send: Request


// === Access ---------------------------------------

@ViewBuilder
func viewForContent(_ content: Content) -> some View {
  switch content {
  case .0(let number):
    Text("Number: \(number)")
  case .1(let numberDescription):
    Text(numberDescription)
  }
}

func handleTap(for action: Action) {
  switch action {
  case .receive(let image):
    ...
  case .send(let request):
    ...
  }
}


// === Initialization --------------------------------

viewForContent(.1("The number six"))

viewForContent("The number six") ❌
// Error: Cannot convert 'String' to 'Int | String'

handleTap(for: .send(myRequest))
handleTap(for: .1(myRequest)) âś…
// Okay, matches tuple behavior 


// === Unsupported ------------------------------------

let content: Content = ...

let number = content as? Int ❌

content.description ❌
// Note: Although both types conform to 'CustomStringConvertible'
// access to 'description' is invalid.

extension Content { ❌ 
  // Error: Anonymous sum types are not extensible 

  func printHello() { 
    print("Hello world!")
  }
}
1 Like

I think this is a great place to start. Focused and aligns well with tuples.
It could then be extended in various future directions through future SE proposals, if needed.

Instantiation, projection, syntactic sugar, automatic protocol conformance forwarding, etc could be added later, in any order, and one and a time. Or not at all.

But this is a good place to start:

typealias Content = Int | String 
typealias Action = receive: Image | send: Request

(I think I'd prefer parenthesis around the declarations, espcially for the labelled variants, but this we could bikeshed later)

1 Like

The OR operator refers to union types in various languages (Scala which recently added union types in order to fully implement the proven sound DOT type calculus, TypeScript and probably many others), therefore it would be misleading if used for other concepts.
Union types are necessarily structural since A | B should be equal to B | A and A | A should either be equal to A or prevented.

Either types are not union types, since the ordering matters and since you can have the same type on both left and right (Either<A, A> would be allowed and Either<A, B> would be different from Either<B, A>). Either types are what we call sum types since if A has n possible values and B has m possible values, then Either<A, B> has n + m possible values regardless of A being eventually equal to B. Sum types, as opposed to union types, do not require to be structural and can already be implemented in Swift using enums. If you want to provide them also as structural types, you will likely use + or (+) instead of |.

It looks like the users' desire of sum types arises from the ability to "sum" (join?) enum cases (mainly for thrown errors?), but that's not what a sum type gives you:

enum EnumA { case a, b }
enum EnumB { case b, d }

The sum type EnumA (+) EnumB obtained from EnumA and EnumB has 2 cases, each one having 2 sub-cases. It does not have 3 cases. It does not have 4 cases. In order to achieve those behaviors you would likely need a language feature appositely designed for enums (which in my opinion would be of doubtful use). The title of this pitch is apt in that sense: "Enum composition" wouldn't be a sum or a union type.

In order to be able to write something like:

func foo(_ x: Int) throws ErrorA, ErrorB -> Int {
  switch x {
  case 0: throw ErrorA
  case 1: throw ErrorB
  default: return x
}

do {
  let y = try foo(2)
} catch ErrorA {
  print("ErrorA")
} catch ErrorB {
  print("ErrorB")
}

you don't want sum types or "enum constructed by joining cases", you want union types which are a no-no (fortunately or unfortunately, depending on the point of view).

9 Likes

I agree that the syntax needs to be worked on.

In my post I wanted to concentrate on the efficacy of the various features proposed, not so much to provide a finalized syntax or an overall concrete feature.

I think there are other use cases, such using anonymous types as a means to avoid unnecessarily creating a special-case nominal type. Another use-case could be to provide a sort of "universally used" type. That is, Dictionary's Element is a tuple instead of a custom type, presumably for ease of use since tuples are general-purpose types.

Perhaps the thread started could clarify what is the intent of this thread, since we are exploring quite different features.