Pitch: Pass-through initializers for enum associated values

Summary of the problem/solution

When declaring an enum with associated values, it's recommended to use a struct or a class and not a tuple.

struct Foo {
  let a: Int
  let b: Int
}

enum Bar {
  case none
  case foo(Foo)
}

When instantiating a Bar, this becomes:

var bar1: Bar = .foo(Foo(a: 1, b: 2)) 
var bar2: Bar = .foo(.init(a: 1, b: 2)) // inferred

My proposition - allow omitting the type name and init call when unambiguous inference is possible:

var bar2: Bar = .foo(a: 1, b: 2)

Detailed problem description

Associated values for enums accept any type. In practice, people usually start off with a primitive value:

case foo(Int)

Extending it when necessary:

case foo(x: Int, y: Int)

And then it gets complicated. Tuples aren't really first class types, which leads to a number of quirks. Member labels are not part of the type and can get lost when passed as parameters. Destructuring occurs in some cases.

This leads a lot of developer to prefer stronger types as associated values:

struct Foo { }
// ...
case foo(Foo)

It's very common that the struct is either entirely specific to the enum case. The struct names could be long, for disambiguation and descriptiveness.

struct OneStructAssociatedValueBelongingToSomeEnum {
  let x: Int
  let y: Int
}
struct AnotherStructAssociatedValueBelongingToSomeEnum {
  let a: Int
}

enum SomeEnumeration {
  case firstEnumerationValue(OneStructAssociatedValueBelongingToSomeEnum)
  case secondEnumerationValue(AnotherStructAssociatedValueBelongingToSomeEnum)
}

However, this leads to a boilerplate problem at the instantiation site.

func calculateValue(param: Int) -> SomeEnumeration {
  if param > 0 {
    return .firstEnumerationValue(OneStructAssociatedValueBelongingToSomeEnum(x: param, y: -1))
  } else {
    return .secondEnumerationValue(AnotherStructAssociatedValueBelongingToSomeEnum(a: param))
  }
}

One could reduce boilerplate by converting the explicit type init to an implicit one:

func calculateValue(param: Int) -> SomeEnumeration {
  if param > 0 {
    return .firstEnumerationValue(.init(x: param, y: -1))
  } else {
    return .secondEnumerationValue(.init(a: param))
  }
}

However, this does not eliminate boilerplate entirely, and makes the code less idiomatic.

Detailed suggestion

Allow the enum associated value syntax to auto-infer an initializer call, when this can be made unambiguously. Using the previous example, this would be possible:

func calculateValue(param: Int) -> SomeEnumeration {
  if param > 0 {
    return .firstEnumerationValue(x: param, y: -1)
  } else {
    return .secondEnumerationValue(a: param)
  }
}

Conditions for applying the sugar:

  • The associated value type has an initializer with typed and labeled argument list L
  • The associated value instantiation expression has been given a tuple with the same typed and labeled argument list L
  • Applying the sugar will not cause ambiguity (e.g. another initializer with non-required parameters)

Effect on source compatibility

None, the change is additive

Effect on ABI compatibility.

None, the change is additive and is syntactic sugar only.

2 Likes

Can you please show the source of this recommendation?

SwiftLint recommends not more than 5 tuple members in associated values, but I recall it being mentioned elsewhere as well.

I think what you’re suggesting is basically [Pitch] Introduce Expanded Parameters

1 Like

I think it would be handy to have a combined syntax that declares a type and the enum case that constructs it, since it is very useful to be able to have a type per case. How about something like:

enum SomeEnumeration {
  case struct FirstEnumerationValue {
    var x: Int, y: Int
  }
  case struct SecondEnumerationValue {
    var a: Int
  }
}

// `case struct` also declares the case using something like the
// "expanded parameters" so you can construct the payload in-line
// in the case constructor
func calculateValue(param: Int) -> SomeEnumeration {
  if param > 0 {
    return .firstEnumerationValue(x: param, y: -1)
  } else {
    return .secondEnumerationValue(a: param)
  }
}
22 Likes

@Joe_Groff That's quite elegant, and I'd love for a feature like that to exist in Swift!

I've got a question though. Do you think such a syntax could extend past types defined within the enum context? Seems to me (as someone with very limited / no knowledge on how the swift compiler works) like it could be a nice way to have union types.

If we had the "expanded parameters" capability, then you could use a pre-existing type from outside of the enum like:

struct ExistingType { ... }

case SomeEnumeration {
  case existing(@expanded value: ExistingType)
}
4 Likes

Having a type-per-enum-case is really handy.

It also lets you pass a case value across function boundaries without "erasing" it up to its enum type, and it can help with pattern matching.

I wrote up a few thoughts in response to the is case pitch: Proposal draft for `is case` (pattern-match boolean expressions) - #17 by AlexanderM

1 Like

Years ago (before there were, as I understand, nice libraries for it), I played around with modeling a JSON type as an enum, and the whole thing worked fantastically until I realized it totally fell down when it came to use-site ergonomics as the burden of writing JSON.string(str) and if case .string(let str) = ... * over and over were a deal-breaker.

* I may have botched the if case syntax here, as one does.

What I recall really needing was something one step more than the case struct above, which covers initializing a new value, but also wrapping (and unwrapping) an existing value. Although some parts of this may not be feasible to implement on top of the current compiler, what I think is the fullest expression of what we'd (or, at least, I'd) like to have is some like the following, essentially a limited case of value subtyping:

struct Foo { ... }
struct Bar { var x: Int }

enum E {
  @subtype case foo(Foo)
  @subtype case bar(Bar)
}

func f(x: Int, foo: Foo) -> E {
  if x > 0 {
    return Bar(x: x)
    // Equivalent to .bar(Bar(x: x))
  } else {
    return foo
    // Equivalent to .foo(foo)
  }
}

This would also subsume the magic of Optional as it would be utterable in Swift as:

enum Optional<Wrapped> {
  @subtype case some(Wrapped)
  case none
}
7 Likes

Would it shew this form?

typealias T = (
    a: Int,
    b: Int,
    c: Int,
    d: Int,
    e: Int,
    f: Int
)
enum E {
    case e(T)
}

Or this?

enum E {
    case e(Int, Int, Int, Int, (Int, Int, Int)) // did I just cheat it?
}

I found tools like this having their own often opinionated idea what language should be, which is somewhat arbitrary. When you go from one project that follows one set of SwiftLint rules to another project that follows a different set of SwiftLint rules (or doesn't use it) it almost feels like you are using a different language dialect.

Nice. This may allow:

extension CGPoint { init(@expanded _ other: CGPoint) {...} }
extension CGSize { init(@expanded _ other: CGSize) {...} }
extension CGRect { init(@expanded _ other: CGRect) {...} }

// all of these are suddenly possible:

CGRect(otherRect)
CGRect(origin: origin, size: size) 
CGRect(x: x, y: y, size: size) 
CGRect(origin:origin, width: width, height: height) 
CGRect(x: x, y: y, width: width, height: height) 

BTW, as written this won't support more than one case with Foo / Bar associated values.

enum E {
  @subtype case foo(Foo)
  @subtype case bar(Bar)
  @subtype case baz(Foo)
  @subtype case qux(Bar)
}

This seems like a slight variation of "TupleLiteralConvertible", that would allow you to use a tuple literal in some cases where structs are expected:

struct Point {
    let x: Double
    let y: Double
}

func transform(point: Point) -> Point { ... }

// Now this:
let newPoint = transform(point: .init(x: 2.5, y: 4))

// could be written as
let newPoint = transform(point: (2.5, 4))

// or possibly
let newPoint = transform(point: (x: 2.5, y: 4))

This tuple conversion would be generally useful, and you could imagine dropping a layer of brackets when used in enum cases:

enum Geometry {
    case point(Point)
    case line(Line)
}

let geo: Geometry = .point(x: 2.5, y: 4)

// could be sugar for
let geo: Geometry = .point((x: 2.5, y: 4))

// which in turn would be convertible to
let geo: Geometry = .point(.init(x: 2.5, y: 4))