Extract Payload for enum cases having associated value

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

Yes, but we’re talking about the additional layer of optionality that is added by the getter of an enum key path because it is not known whether the enum value is of the case represented by the key path or not. This is the same issue as the optional added by the Dictionary subscript, except in this case there is no reasonable way to interpret nil at that level of optionality in a setter (whereas Dictionary is able to interpret nil as “remove the element”).

2 Likes

Thanks for clarifying.

Let's suppose we have something like

class CasePattern<Root, Value> {
    ...
}

class ReferenceWritableCasePattern<Root, Value>: CasePattern<Root, Value> {
    ...
}

These classes will have append functions to combine them with other CasePatterns, or regular KeyPath to be used when the associated value is Struct, for example

Then for the subscripts

extension Any {
    subscript<Root: Self, Value>(case pattern: CasePattern <Root, Value>) throws -> Value { get }
    subscript<Root: Self, Value>(case pattern: ReferenceWritableCasePattern <Root, Value>) throws-> Value { set, get }
}

The subscript will throw when the case is not matching.

Usage

enum Foo {
    case bar(String)
    case baz
}

let e: Foo

let value = try? e[case: \.bar] //String?
// or, if for some kind of logic e can't be any different than bar and you want to recover from mismatch
do {
    let value = try e[case: \.bar] //String
} catch {
    print("I was really expecting that e was .bar... what's wrong?")
}

// updates
var e: Foo
try? e[case: \.bar] = "Hello CasePattern!!"

// or
do {
    try e[case: \.bar] = "Hello CasePattern!!"
} catch {
    print("I was really expecting that e was .bar... what's wrong?")
}

even with the try, ergonomics is still nice imho. I can build it with my EnumKit if you would like to taste how it would look like.

What do you think?

EnumKeyPath subscript would add a layer of optionality, so you will still be able to know why the result is nil.

s[keyPath: \State.error] returns a String?, and e[keyPath: \Baz.bla] will simply returns a String??

I don't think I like it, and This doesn't solve the fact that KeyPaths with enum would be "conditional" while with other types they aren't. To use KeyPaths for this job with enums kinda makes me feel like wanting to use a Book struct for a Person just because they both have 2 strings and a date.

I'm not sure about “no reasonable way”, since it seems like you could reasonably define it as ignoring the setter, which would make the read and write operations with the keypath “round trip” in a sensible way (i.e. reading using the keypath then writing the same value back is a no-op). You would be left with a similar sharp edge as with Dictionary<T?>, where care has to be taken with .none vs .some(.none) but that's nothing new, just the inherent nature of nested optionals.

3 Likes

That's an interesting way to put it, thanks @jawbroken. And this removes a blocker in the exploration of setters.

For what it's worth, this questioning applies to both regular key paths, and the distinct "patterns" or "pattern matching conditions" as proposed by @GLanza.