Extract Payload for enum cases having associated value

I'm still trying to figure out everything in this thread. Something I found out about in another thread was "optics," a general term for "lenses" and "prisms." A lens is a field (or nested field) of a product type. A prism is an optional access point to a case of a sum type (or an aspect of an unspecified algebraic type that is only sometimes valid). I'm still trying to grok optics, but your idea seems like it's dabbling in the same area as prisms. (The article I just re-read noted that a lens is just a prism that the compiler knows is 100% reliable.)

I agree, like another poster mentioned, that this should be part of the core language. A protocol isn't appropriate because there shouldn't be customization. It's like a switch; an enum type cannot opt-out of being targeted by a switch, it cannot partially opt-out, and it cannot provide a quirky override of how the switch process it (It's fixed by someone on the Swift development team.). The switch statement's calculus over enum types is total and absolute, and the same should apply to this facility. Built-in operations using (something like) key paths to reference fields is probably better than dumping a bunch of (nested) named members into a type that share the namespace.

There's been discussion of write access. For read access, we can do:

let x: MyCaseType? = myVariable[use: \MyEnum.myCase.self]

(where self references the entire payload tuple, or Void for singular cases). But what happens when writing:

enum MyEnum {
    case one
    case two(a: Int, b: Double)
}

var y: MyEnum = .one
y[use: \MyEnum.two.b] = 4.5

The object's case was reset, but we didn't initialize the entirety of the .two tuple, just one member. Swift deliberately does not have defaulted initialization, so the .a payload member has an illegal garbage value, and shouldn't be allowed at compile-time if possible, or run-time at worst. Are we going to restrict writing only when the leaf case is changed in whole and no intermediate case in the chain has skipped sibling members?

I'm happy with having KeyPaths as long as they are of a different type: EnumKeyPath.

You might want the same functionality for an arbitrary OptionSet, hence the protocol. Functions of the protocol can still only be used on concrete types, therefore it all remains strongly typed. You would write cases like you do for a switch already.

I don't think the compiler should stop you from doing that, and I don't possibly see how it could, given the fact that y can be any case of MyEnum.

y[case: \.two.b] = 4.5

// is a shortcut to

if case let .two(a, _) = y {
    y = .two(a: a, b: 4.5)
}

while the expression y[case: \.one] = 4.5 Should be forbidden by the compiler because one does not have an associated value that can possibly contain 4.5.

Note that while we are trying to give to enums capabilities today available only to structs (KeyPath), enums and structs are objectively different. Overloads on structs variable is not possible, therefore I believe that enum cases should not and cannot be considered as structs properties. They are more similar to functions for this aspect. There is no concept of KeyPath for functions yet.

enum Foo {
    case bar(a: Int)
    case bar(a: Int, b: Int)
    case baz(String)
    case baz(String, String)
    case bla
}

This is a perfectly valid enum. \.bar or \.bar.a is ambiguous, Foo.bar(a:) (possible today) isn't.

Resistance is futile.

More seriously, key paths come with an established class hierarchy that has been carefully designed. Asking for another type out of the blue, without any justification, is odd and looks not constructive - at least to me. Not being familiar with key paths is not really an excuse. Why not, instead, becoming fluent with them? If they fit, good. If they don't fit, then we must know why.

:rofl:

Carefully designed for properties. At the time enum were not on the table, as far as I know. Enum cases are not properties and because they allow overloading they are not even alike to properties. They are more similar to functions, imho.

I agree. That's the reason why this pitch was not born as a spin-off of the KeyPaths pitch, and it was proposed as an alternative.

They don't fit, because to disambiguate overloading cases you need "more" than just the case name. You need specific informations about the payload, such as types and/or labels for each element of the tuple that composes the associated value. So you either need a different "KeyPaths alike" construct, or you need to accept that you can't have KeyPaths. Maybe having something completely different (say pattern).

We can also accept the idea that product types and sum types in swift are just different. What you have with structs does not necessarily apply to enums, and the "do nothing" becomes a possibility. What the community wants? This is the ultimate question in my opinion.

  1. Do we want to access associated values with better ergonomics than switch and pattern matching?
  2. Do we want KeyPaths, or it's better to accept the fact that cases are not properties, therefore keypaths are not a good fit?

Answering these questions will help to go forward for a formal proposal to achieve a better ergonomics if the answer to the question 1. is yes.

  • I do think we need better ergonomics
  • I think KeyPaths are not the way, but if the community has a different feeling I'm ready to accept that.
  • Overloading cases makes the difference between cases and structs properties that remark the importance of not having KeyPaths for enums the way we know them today.

This is my position about the matter, today.

No, @GLanza, no. It's not because they don't fit today that they can never fit. You can for example, use the information provided by @Jumhyn above.

In order to deduce for good that a type does not fit, you have to show that its apis and behavior exhibit an inconsistency or an unpluggable hole.

Can built-in key paths deal with enums?

The above sample code from @stephencelis uses an EnumKeyPath type because Stephen could not rely on language-provided key paths: they don't provide any public initializer. Is the presence of this type the side effect of a constraint that could be lifted eventually, or something that is absolutely necessary?

Let's see! I apologize in advance: this post is long, and not even complete.

The existing hierarchy of key paths is the following:

All those can interact with actual values with the fundamental key path subscripts:

extension Any {
    subscript(keyPath path: AnyKeyPath) -> Any? { get }
    subscript<Root: Self>(keyPath path: PartialKeyPath<Root>) -> Any { get }
    subscript<Root: Self, Value>(keyPath path: KeyPath<Root, Value>) -> Value { get }
    subscript<Root: Self, Value>(keyPath path: WritableKeyPath<Root, Value>) -> Value { set, get }
}

OK, so let's start performing deductions. Let's start from a simple enum, remind some fundamentals about it, and give a few desiderata.

enum MyEnum {
    case empty
    case void(Void)
    case never(Never)
    case value(Int)
    case namedValue(a: Int)
    case values(Int, String)
    case namedValues(a: Int, b: String)
    case tuple((Int, String))
    case namedTuple((a: Int, b: String))
}

// Fundamentals
MyEnum.empty       // Type: MyEnum
MyEnum.void        // Type: (Void) -> MyEnum
MyEnum.never       // Type: (Never) -> MyEnum
MyEnum.value       // Type: (Int) -> MyEnum
MyEnum.namedValue  // Type: (Int) -> MyEnum
MyEnum.values      // Type: (Int, String) -> MyEnum
MyEnum.namedValues // Type: (Int, String) -> MyEnum
MyEnum.tuple       // Type: ((Int, String)) -> MyEnum
MyEnum.namedTuple  // Type: ((a: Int, b: String)) -> MyEnum

Our first desire is that the key path subscript returns the "payload" if the enum has the matching case. This optionality is expressed with the standard Optional type:

DESIRE 1

We can extract an optional Int with the key path \MyEnum.value:

let e: MyEnum
e[keyPath: \.value] // Type: Int?

We deduce from the fundamental key path subscripts: \MyEnum.value has the type KeyPath<MyEnum, Int?>. And we can write a naive hypothesis: a key path has the type KeyPath<MyEnum, T?> where T is the type of the payload.

Is it consistent? With the void case, it's quite OK:

\MyEnum.void       // Type: KeyPath<MyEnum, Void?>
e[keyPath: \.void] // Type: Void?

The tuple cases should be no different. After all, a tuple is just a value, right?

\MyEnum.tuple            // Type: KeyPath<MyEnum, (Int, String)?>
\MyEnum.namedTuple       // Type: KeyPath<MyEnum, (a: Int, b: String)?>
e[keyPath: \.tuple]      // Type: (Int, String)?
e[keyPath: \.namedTuple] // Type: (a: Int, b: String)?

With compound enums that wrap several values, do we have a choice? How to express a list of values without a tuple?

// What else?
\MyEnum.values            // Type: KeyPath<MyEnum, (Int, String)?>
\MyEnum.namedValues       // Type: KeyPath<MyEnum, (a: Int, b: String)?>
e[keyPath: \.values]      // Type: (Int, String)?
e[keyPath: \.namedValues] // Type: (a: Int, b: String)?

Those key paths to compound values are identical to key paths to the same values, wrapped in a tuple. We have introduced our first inconsistency:

// Identical
\MyEnum.values      // Type: KeyPath<MyEnum, (Int, String)?>
\MyEnum.tuple       // Type: KeyPath<MyEnum, (Int, String)?>

// Different
MyEnum.values       // Type: (Int, String) -> MyEnum
MyEnum.tuple        // Type: ((Int, String)) -> MyEnum

// Identical
\MyEnum.namedValues // Type: KeyPath<MyEnum, (a: Int, b: String)?>
\MyEnum.namedTuple  // Type: KeyPath<MyEnum, (a: Int, b: String)?>

// Different
MyEnum.namedValues  // Type: (Int, String) -> MyEnum
MyEnum.namedTuple   // Type: ((a: Int, b: String)) -> MyEnum

QUESTION 1

Is it OK if the type of a key path to a case that contain N values is identical to a type of a key path to a case that contains the same N values, wrapped in a tuple? Despite the fact that the static factory methods for those cases don't have the same types?

It is a difficult question, if only because we start to blur the definition of the "type of the payload". It would be nice if it could be unambiguously defined. It looks it can't. We start to understand that enum key paths may have to use their own, ad-hoc, definition of the type of the payload, driven by key path usage.

Let's put the question on hold until we get more information. We still have never, empty, and namedValue to deal with.

Never can be dealt with quite consistently:

\MyEnum.never       // Type: KeyPath<MyEnum, Never?>
e[keyPath: \.never] // Type: Never? (always nil in practice)

The empty case is difficult. It has no payload. Should we treat it like the void case?

\MyEnum.empty       // Type: KeyPath<MyEnum, Void?>
\MyEnum.void        // Type: KeyPath<MyEnum, Void?>

Or with a bool?

\MyEnum.empty       // Type: KeyPath<MyEnum, Bool>

Or with a compiler error?

// Compiler error: key path cannot refer to value-less enum case
\MyEnum.empty

QUESTION 2

How should we deal with key paths to a non-existent payload? The same as Void payloads? With a boolean? With a compiler error? In another way?

Remains the case of namedValue. The language has an ambiguous relation to single-valued tuples. They exist, burried inside the language guts, but they can't be expressed in userland. We have no choice:

// \MyEnum.namedValue    // Impossible Type: KeyPath<MyEnum, (a: Int)?>
\MyEnum.namedValue       // Type: KeyPath<MyEnum, Int?>
e[keyPath: \.namedValue] // Type: Int?

It is time for the second desire: composed key paths.

@Joe_Groff, whose opinion matters, wrote:

I'll thus suppose that tuple elements are allowed in key paths, even if they are not yet. My goal is to look for inconsistencies between enums and key paths.

DESIRE 2

Those key paths should be expressible:

enum MyEnum {
    ...
    case values(Int, String)
    case tuple((Int, String))
    case namedValues(a: Int, b: String)
    case namedTuple((a: Int, b: String))
}

\MyEnum.values?.0        // Type: KeyPath<MyEnum, Int?>
\MyEnum.values?.1        // Type: KeyPath<MyEnum, String?>

\MyEnum.tuple?.0         // Type: KeyPath<MyEnum, Int?>
\MyEnum.tuple?.1         // Type: KeyPath<MyEnum, String?>

\MyEnum.namedValues?.a   // Type: KeyPath<MyEnum, Int?>
\MyEnum.namedValues?.b   // Type: KeyPath<MyEnum, String?>
\MyEnum.namedValues?.0   // Type: KeyPath<MyEnum, Int?>
\MyEnum.namedValues?.1   // Type: KeyPath<MyEnum, String?>

\MyEnum.namedTuple?.a    // Type: KeyPath<MyEnum, Int?>
\MyEnum.namedTuple?.b    // Type: KeyPath<MyEnum, String?>
\MyEnum.namedTuple?.0    // Type: KeyPath<MyEnum, Int?>
\MyEnum.namedTuple?.1    // Type: KeyPath<MyEnum, String?>

Fortunately, they are all consistent with the types of the basic key paths:

\MyEnum.values      // Type: KeyPath<MyEnum, (Int, String)?>
\MyEnum.tuple       // Type: KeyPath<MyEnum, (Int, String)?>
\MyEnum.namedValues // Type: KeyPath<MyEnum, (a: Int, b: String)?>
\MyEnum.namedTuple  // Type: KeyPath<MyEnum, (a: Int, b: String)?>

Now let's get wild.

DESIRE 3

Payloads that contain a single named value profit from the same support for composed key paths as payloads made of several named values

enum MyEnum {
    ...
    case namedValue(a: Int)
}

\MyEnum.namedValue?.a    // Type: KeyPath<MyEnum, Int?>
\MyEnum.namedValue?.0    // Type: KeyPath<MyEnum, Int?>

This is wild because this does not match at all the type of the basic key path, where there is no tuple and no a member is sight:

\MyEnum.namedValue // Type: KeyPath<MyEnum, Int?>

I don't know enough how key paths are implemented, and if the compiler could make it work despite the abscence of the required information at the type level.

QUESTION 3

Do we allow the tuple extraction syntax for key paths to payload made of a single named value?


Sorry for this long post. This is the end of this first exploration, which covers the read-only side. SE-0155 has not been covered. Writable key paths have not been covered.

Yet I have one preliminary conclusion: no contradiction has been exhibited between the built-in read-only KeyPath type and enums. There is no evidence, in this exploration, that we need a new, dedicated, EnumKeyPath type.

Some questions have been asked, and they may hide traps. On top of that, the third desire expressed above, about payloads that contain a single named value (\MyEnum.namedValue?.a), may require some special compiler support. But this desire can be discarded as not-implementable. And the questions require more exploration before they can turn into a deal-breaker for KeyPath and enums.

Thanks for reading!

8 Likes
enum MyEnum {
    case empty
    case void(Void)
    case never(Never)
    case value(Int)
    case namedValue(a: Int)
    case values(Int, String)
    case namedValues(a: Int, b: String)
    case tuple((Int, String))
    case namedTuple((a: Int, b: String))
}

Here is an example:

extension MyEnum {
    var value: Int? {
         self[keyPath: \.value]
    }
}

Computed properties get for free a keyPath that in this case happens to be the same as the KeyPath that would be generated for the value case. Should the compiler stop you from creating computed vars with the same name as the existing case? What about existing code where this computed properties are pretty common?

having different KeyPaths types and subscript would fix this.

extension MyEnum {
    var value: Int? {
         self[case: \.value] // case associated type
    }
}

e[keyPath: \.value] // property
e[case: \.value] // case associated type
1 Like

OK, good question.

A solution which does not require inventing a whole new kind of "case key path" is that properties have priority on key paths generation. When a property is defined, the matching case key path is not synthesized. The code above would compile, but exhibit an infinite loop at runtime, as it does today.

On a side note, I understand that you absolutely want to avoid regular key paths. After all we really have to make sure they fit the bill. A good dose of honest critic is useful.

But your EnumKeyPath are not key paths at all. Those are something else completely. Calling them "enum key paths" add some confusion for other forum members. Could you please come up with a different name?

By contrast, it should be absolutely clear for everybody that in @stephencelis's sample code, the EnumKeyPath totally belongs to the standard KeyPath hierarchy. He would certainly not ruin his stated goal of struct and enum convergence by introducing something which works only with enums.

They are, today, but I personally support the courageous ambition and efforts of people who want to close the gap. If it is possible, then we'd enrich the language expressiveness without engraving existing silos forever.

What you have with structs does not necessarily apply to enums, and the "do nothing" becomes a possibility. What the community wants? This is the ultimate question in my opinion.

  1. Do we want to access associated values with better ergonomics than switch and pattern matching?

It looks that the desire for this is widely shared by many Swift users.

There is a "sub-community" which would prefer focusing on improvements to pattern matching. But they are not very vocal, unfortunately, so we don't really know what it could give.

  1. Do we want KeyPaths, or it's better to accept the fact that cases are not properties, therefore keypaths are not a good fit?

To this question, my personal answer is that unless key paths are proven to be unfit, they should be prefered.

  • I do think we need better ergonomics
  • I think KeyPaths are not the way, but if the community has a different feeling I'm ready to accept that.
  • Overloading cases makes the difference between cases and structs properties that remark the importance of not having KeyPaths for enums the way we know them today.

This is my position about the matter, today.

Thanks for reminding that our positions are temporary, based on personal preferences and current knowledge :sunny:

1 Like

This leads to issues and confusion:

extension MyEnum {
    var value: Int? { // While it has the same name, this is something different than the value case.
        5
    }
}

This is not hard to imagine in a real world use case:

enum MyError: Error {
    case message(String)
    case notFound(String)
    case noConnection(String)
    case genericError

    var message: String? {
        switch self {
        case let .message(e): return e
        case let .notFound(e): return e
        case let .noConnection(e): return e
        case genericError: return nil
        }
    }
}

e[keyPath: \.message] // do I get the case .message 
// or the property that represents the associated string to any case?

Not at any cost. I'm not closed to this possibility. I just don't think they fit. We want convergence for enum and structs but enums diverge from structs for so many things. it's not only overloading cases, you can have also computed properties with same name of cases. Functions with same name that still increase the confusion. When I see enums and structs I don't see two lines that tend to converge slowly, I see two lines that goes in completely different ways.

1 Like

For people who write confusing code, sure ¯\_(ツ)_/¯

It's possible. We shouldn't help confusion with an additional source of bugs. And already saying "Ok, if you have a computed property, the property win" is divergent to what happens on structs, because structs don't have this "problem" in the first place.

I proposed the name pattern. I don't think it's a KeyPath in the first place.

Struct Person {
    var name: String
}

enum State {
    case loading
    case error(message: String)
}

The two "keyPaths" p[keyPath: \Person.name] and s[keyPath: \State.error] mean two completely different things.

The first means "give me the string for the property name of an instance of type Person."
The second means "give me the string for the associated value of the enum case, IF the instance matches the case .error"

The first has no uncertainty. The second is conditional.

now consider this:

Struct Foo {
    var bar: String?
}

enum Baz {
    case bla(String?)
    case foobar
}

In this case e[keyPath: \Foo.bar] when nil means "the property is nil". Simple.
e[keyPath: \Baz.bla] when nil might either mean that bla's associated value is nil, or that the instance is not bla.

They clearly have different meanings, they should not be named the same, and in fact they should not be the same type at all. One is a KeyPath, the other is a pattern matching condition.

I would say that the latter requires a pattern matching operation. The conclusion is the same, in my opinion, but I think it frames the discussion a bit differently.

Today, KeyPaths are verified at compile time. The requirement for a pattern match means that using them for enums requires a runtime component. Do we want to bifurcate KeyPaths in this way?

1 Like

More than "do we want?" I would say "is it right?". Having the same construct meaning and acting different depending on the instance it is applied... I don't know. If the community feels like it's the way, I'll commit and disagree. I repeat that personally I believe we need something different. I call them Patterns, CasePattern is maybe a better name. CasePattern<Root, Value> and since they would be generated just for enums, you won't even need to disambiguate about the fact that Root has to be an enum, somehow.

It looks like there is a confusion in the nature of key paths in this sentence.

Current key paths already have a runtime component. When you evaluate user[keyPath: \.team.name], the information that the getter for Player.team and then the getter of Team.name must be called at runtime has to be encoded somewhere. It is encoded in the KeyPath instance itself, ready to be used whenever it is activated.

Proof:

struct Team { var name: String }
struct Player { var team: Team }

// A totally erased key path
let kp: AnyKeyPath = \Player.team.name

let player = Player(team: Team(name: "red"))
player[keyPath: kp] // "red", without any compile time information

You don't actually have to use a complex key path - the illustration was just more striking.

So no, the runtime aspect of pattern matching is not in contradiction with key paths as we know them.

2 Likes

It is difficult to explore the subject because Swift the language does not support subscripts with a different type for the getter and the setter.

Enum key paths would require an optional getter, and a non-optional setter. The getter returns an optional payload which is not nil if and only if the enum has the matching case. The setter requires a non-optional payload, because "setting the payload to nil" has no meaning.

I note that the original pitch for enum key paths did not include writable key paths.

In terms of functional programming optics, it looks like sum types can define "lenses", but product types can only define "prisms". In our case, the struct key paths getter and setter use the same type: they are lenses. But enum key paths getter and setter don't use the same type: they are prisms. Lenses are prisms, where it happens that getter and setter use the same type. I'm just not well versed enough in the techniques used by functional languages in order to bring them together and flatten their differences where possible. Obviously serious variance and contravariance juggling is necessary.

I reach my personal limits here: I'm not, right now, able to explore writable enum key paths and exhibit the changes they would bring to the language. My skills and general culture are too limited.

What I'm sure of, though, is that they will require a lot of changes. The Core Team will ask to split them into distinct proposals. I predict that writable enum key paths require an "umbrella proposal", split into several sub-proposals. And we'll need the most talentuous people.

I wish somebody proves me wrong :sweat_smile: Stephen, any hope? Core Team members, any advice?

2 Likes

Would this not require an optional payload for the setter?

enum E {
    case a(Int?)
}

var e = E.a(3)
e[case: a] = nil

Yes, this is very true. Maybe it motivates something like EnumWritableKeyPath<Root, Value>: KeyPath<Root, Value?>. I agree that this is potentially a much larger change than readable key paths for enums and might need to be split out into a separate proposal. Nevertheless, it’s good to at least sketch out and explore what an eventual solution might look like.

1 Like