Automatically derive properties for enum cases

I wanted to circle back to this comment from @Joe_Groff:

Has there been any recent discussion about this? I'd love for enums to be a bit more ergonomic via optional-chaining. Key path support would be a nice, free bonus! I can imagine it working simply for cases with a single associated value:

enum Result<Value, Other> {
  case value(Value)
  case other(Other)
}

let result: Result<Int, String> = .value(1)
result.value // Int?.some(1)
result.other // nil

Cases with multiple associated values could return tuples:

enum Color {
  case rgba(red: Float, green: Float, blue: Float, alpha: Float)
  …
}

let color = Color.rgba(red: 1, green: 0, blue: 0, alpha: 1)
color.rgba      // (red: Float, green: Float, blue: Float, alpha: Float)?.some(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
color.rgba?.red // Float?.some(1.0)
color.rgba?.0   // Float?.some(1.0)

So how about cases with no associated values? Do they return Optional<Void>?

enum TrafficLight {
  case green
  case yellow
  case red
}

let trafficLight = TrafficLight.green
trafficLight.green  // TrafficLight?.some(())
trafficLight.yellow // nil

Probably not? How about returning Bool instead?

if trafficLight.green {
  // …
}

This is better, but we probably want to prefix with is and camelcase the accessor.

trafficLight.isGreen  // true
trafficLight.isYellow // false

While we're at it, why not derive these boolean accessors everywhere?

result.isValue // true
result.isOther // false

color.isRgba   // true

Edit: Here's a link to a draft!

13 Likes

This would be great. I'm personally not a fan of magically deriving new names from each case for boolean properties; I'd rather the case name alone serve as an accessor to its payload. (Wouldn't it be nice if Bool were a typealias for ()? ?)

3 Likes

Agree.

Note that, with SE-0155, we would have to support compound names because it is now permitted (but not yet implemented) for the same base name to be used for multiple cases. But that would seem to create ambiguity with binding to the case as a function type. Not sure how to square that circle.

Totally agree. I only suggested it because it seemed like the most "Swift" solution at hand right now. I was also unsure how to solve for case foo vs. case foo(Void).

It would seem to maybe require support for property overloads, which seems like a dicey proposal. The only other choice I see is revisiting the unimplemented decision and suggest its removal by way of any proposal made after this pitch. Would love to hear other solutions, though, that don't conflict!

Another way we could cut the compound-payload cloth would be to form accessors from each label, instead of from each case. If a label exists with the same type in every payload (and the type is frozen), it could be a non-optional accessor for the common component; otherwise, produce an optional accessor for that part of the payload, e.g.:

@frozen enum Foo {
  case bar(x: Int, y: String)
  case bas(x: Int, z: Float)
}

let foo: Foo

foo.x // => Int, from either bar or bas payload
foo.y // => String?, from bar payload
foo.z // => Float?, from bas payload
2 Likes

My concern is what to do in the case of:

@frozen enum Bar {
  case a(b: Int, c: String)
  case a(b: String, d: Int)
}
2 Likes

I would prefer something like

Yes please. I recently wrote some AST node types like Expression and Statement where enum was the absolute right way to express that, and each had a location label in its payload. Having that property synthesized would have been fantastic.

Agreed—this is where trying to auto-synthesize individual payload accessors seems to get hairy. If we had a standard Either type, I can't think of anything better to do here than just synthesize a has Either<(b: Int, c: String), (b: String, d: Int)>. But we'd need variadic generics to make this work for more than two overloads.

If Either is an enum, then it's turtles all the way down!

6 Likes

Just to throw this option out there, we could just skip property synthesis for mismatched types in case / label name collisions if we can't find an acceptable solution right now. We can always circle back on it, and this would be a really useful feature even without handling that case, so I'd hate to see it be held up or not go anywhere because of that.

I looked through a bunch of my enums, and synthesized is* boolean properties seemed like they would always read naturally, so that feels like a pretty safe transformation to me. The only issue I see with it is for acronyms, i.e. rgba would ideally translate to isRGBA instead of isRgba, but I don't know if there's a good way to make that happen.

4 Likes

I'm not a fan of this option because it's punting the task of design for expediency. If it is not possible to find a solution now, what's to say that there is a solution to be found later? It's critical that features added to the language be compatible with each other, and if that cannot be made to happen, then the solution must be to revise existing features and not to pile on more.

2 Likes

Point taken, though I'm thinking about this from a perfect vs. good perspective rather than an expediency perspective. I'm happy for discussion to continue as long as there are ideas to work through. I just don't want to get to a point where this stops moving forward because we get hung up on (what I consider to be at least) a relatively minor edge case. imo this would be a welcome feature even if no solution was ever found for it.

1 Like

All responses so far seem to be in favor of adding this kind of functionality. We just need to work out the details with a proper proposal and hash out outstanding issues. I'll try drafting something in more detail soon. If anyone wants to pitch in, lemme know!

2 Likes
foo.bar?.x // => Int?, from bar
foo.bas?.x // => Int?, from either bar or bas payload
foo.bar?.y // => String?, from bar payload
foo.bas?.z // => Float?, from bas payload

Making the base name an optional tuple would work nicely nice - it would allow all the properties of a case to be unwrapped in one go. When there's only one associated type, like Optional.some, the base name could represent the associated value instead.

(Ideally, a case with a single associated type with a label would translate to a labelled 1-tuple, but we don't have those yet)

Edit: I should at least read the first post of the thread I'm posting in…

2 Likes

This is what I pitched in the first post. The issue of ambiguity occurs when case names overlap, a feature that was accepted in SE-0155 but hasn't been introduced yet.

An example from that proposal:

enum SyntaxTree {
  case type(variables: [TypeVariable])
  case type(instantiated: [Type])
}

let tree = SyntaxTree.type(variables: [])
tree.type // Which case does this refer to?

We'd be unable to derive these properties since their base names overlap. This means we'd either need to reconsider this, or provide some weird kind of named property access.

let tree = SyntaxTree.type(variables: [])
tree.type(variables:) // Very strange syntax!
1 Like

Given that I've yet to hear any arguments why we need this very strange feature, and given that it stands in the way of other clearly useful features, I think it's fine to propose reconsidering it.

3 Likes

This is a long-desired addition, thank you for driving it forward! I would be happy to help with drafting (or reviewing) the proposal if you would like.

One related topic that may (or may not) make sense to tackle as part of this proposal is synthesizing properties for associated values which are present in all cases with the same label and type. This has also been requested many times in the past.

1 Like

Maybe this would work better

let tree = SyntaxTree.type(variables: [])
tree.type //  none because it’s incomplete 
tree.type::variables // force the associated value to be part of the naming if it’s ambiguous might fix it ?

I'd definitely love to see something happen in regards to the ergonomics of enums. Earlier, I posted a pitch within the same domain ([Pitch] Enum Case Constraints), but it doesn't look like it's taken off. If you want to borrow anything from it, please go ahead!