Enum Case KeyPaths

While I'm all for teaching people to use as coercions for type disambiguation, I think what you're describing is pretty close in spirit to overloading APIs on return type, which the core team has previously stated they are determined never to do deliberately.

If it came down to choosing between the two options solely for the purpose of disambiguation, all other things being equal, I'd actually much rather we have a new syntax rather than a new type. This is not least because of the backdeployment issues with a new type mentioned above, but also because the as syntax is admittedly clunky when the whole type is spelled out on the right-hand side, as well as other considerations. Now, a new key path subclass may be required for other reasons, but I'd strongly urge that we decide based on those merits rather than hoping to have something on which to hang an as coercion.


Personally, I think a slight tweak on @mayoff's syntax looks great to me:

Since \Color.blue denotes a "key path to Color.blue", then \case Color.blue can denote "a case key path to Color.blue.

This naturally points to related spellings that could refer to static properties without requiring users to know about metatypes (Foo.Type)—namely, \static Foo.bar—and to functions—namely, \func Foo.bar.

2 Likes

This works well enough 1 layer deep, but what about keypath chaining?

enum Foo {
    case a(String?)
}
struct Bar {
    var foo: Foo   
}

let bar = Bar(foo: .a("hello"))
let firstLetterOfAFoo = bar[keyPath: \case Bar.foo.a?.first] // case prefix doesn't make sense here

Things break down quite quickly as soon as property access gets in the mix. Bar is a struct, not an enum, but somehow it needs to be prefixed case if an enum case is to be accessed anywhere in the chain. That's rather odd.


I don't find this to be the case at all (pardon the pun), especially now that we have type placeholders:

let kp = \Color.blue as KeyPath<_, Bool> // instance member key path
let kp = \Color.blue as KeyPath<_, Void?> // case keypath

Let's also remember, the need for disambiguation is the very exception, not the rule. I don't think we should change the entire spelling just to accommodate 5-10% of the use cases.

I'd like to hear your thoughts on this.

3 Likes

Well, using type placeholders, spelling out the whole type isn't necessary:

let kp = \Color.blue as KeyPath<_, Bool>
let kp = \Color.blue as KeyPath<_, Void?>

:slightly_smiling_face:

EDIT: @davdroman beat me to it!

But yeah, I see where you're coming from. I agree it probably doesn't make sense to introduce a new type solely for the purpose of disambiguation, but if there's enough leeway in the existing language to cover all the disambiguation scenarios, I'm not super strongly motivated to solve it with the introduction of case paths.

I'm all for a more general solution that covers other cases as well and has case path disambiguation fall out of it, I just don't think we need to rush to invent that syntax as part of this proposal.

2 Likes

Could we piggyback on trailing ? syntax to disambiguate "opening" a particular case?

enum Color {
  case blue
  …
  var blue: Bool { … }
}

\Color.blue  // KeyPath<Color, Bool>
\Color.blue? // KeyPath<Color, Void?>

This syntax would hopefully fit in nicely with existing dot/optional-chaining syntax, and even pave the way for more general syntax outside of key/case paths:

let color = Color.generic("periwinkle")
color.generic?       // Optional("periwinkle")
color.generic?.count // Optional(10)

We could then even think of existing optional-chaining syntax as a "shorthand" where ? is the same as .some?.

12 Likes

I agree it wouldn’t be great to have a situation similar to type overloading. But instead of introducing disambiguation syntax, we should make the key path to the case unutterable. If the user explicitly declared a property with the same name as the case they likely wanted it to be associated with that case. As mentioned before, if there’s a need to get the optional type that would come out of the case key path, the user could manually write a computed property.

Also, @Joe_Groff initially mentioned the proposed feature, where the compiler would synthesize computed properties for all cases. With this model, case key paths could be thought of as referring to these properties. E.g.

enum Enum { 
  case a
  var a: Void? { … } // synthesized
}

\Enum.a // like it refers to the computed property

So if the user explicitly writes a computed property a, they probably want to override the synthesized declaration. Since two computed variables cannot have the same name in a type scope, no computed property would be synthesized by the compiler. So it wouldn’t make sense to be able to construct a key path to the original, synthesized declaration; you could only do that for the custom declaration.

I think the compiler details (synthesis vs. syntax) aren't super important, but ideally \Enum.a would be more than just a get-only reference to a computed property and also include the "embed" functionality.

5 Likes

Yeah embedding makes sense. And my main point remains that cases would not just be used for construction but also for projection (reading) and embedding (writing). For source compatibility reasons, we must necessarily support declaring a property with the same name as the case. When this happens, the reading/embedding originally provided by the compiler should be completely overridden and not offered as an alternative.

The confusion could also be removed by actually introducing instance level, optional accessors for cases, which so many people have been clamouring for. Perhaps you would need to conform the enum to something like CaseAccessible for it to work.

Absolutely genius. This is my new headcanon for Swift optionals. So elegant.

1 Like

The language workgroup discussed this proposal during our most recent meeting, and we'd like to see a few things planned out before proceeding with bringing this proposal to review:

  • We would like to have a clear idea of how projection syntax should look to access the payload of an enum case from an enum value. We don't need this feature to be implemented and accepted as a prerequisite to reviewing enum key paths, but we want to make sure that convergent evolution is possible here between enumValue.<enum case reference> and \EnumType.<enum case reference>, however that looks.
  • Although this proposal does not provide an ability to write through enum key paths, or to inject a payload into the enum case to produce a value of the enum, we want to be sure that we can add that functionality in the future. We should have reasonable confidence that the design and implementation allows for key paths involving enum cases to gain those capabilities in the future.
  • Relatedly, it would also be good to have an idea of how in-place mutation of conditionally-available storage should work in the language, such as getting an inout handle to an enum case payload, since this should relate to how conditionally-writable key paths involving enum cases are written through.

Again, we don't need full proposals or implementations for these related features; we only want to make sure we're incrementally evolving the language in a direction that aligns with future directions.

37 Likes

Thanks for the update, @Joe_Groff! I was wondering what the next steps are. Would it be for @Alejandro and anyone he invites to work on the proposal to flesh out the original proposal with a proposed syntax? I'm also curious if the language workgroup has any initial ideas and/or preference for potential syntax?

3 Likes

I've always found CasePaths a bit confusing, what I would personally prefer, and which would solve other issues as well, is a set of conditionally synthesized, mutable optional properties like this:

enum Asset: PropertyAccessible {
    case remote(URL)
    case local(Image)

    // Synthesized:

    var remote: URL? {
        get {
            guard case .remote(let url) = self else { return nil }
            return url
        }
        set {
            self = .remote(newValue)
        }
    }

    var local: Image? {
        get {
            guard case .local(let image) = self else { return nil }
            return image
        }
        set {
            self = .local(image)
        }
    }
}

var asset = ...

let urlString = asset.url?.absoluteString

asset.remote = URL(...)

This should make normal KeyPaths behave as expected, with the added bonus of having these accessors available in all context. Personally I mostly need this outside of KeyPaths.

The only weird thing here is that the setters really shouldn't accept nil, which is an issue we also see with optional subscripts.

Other than that I don't see any obvious downside with this approach, it even has the advantage of not muddying the waters with respect to static vs instance KeyPaths.

3 Likes

IIRC the CasePaths from pointfree wouldn’t change the enum case, only the associated value if the case matches.

I personally find this pattern to be quite counterintuitive; local's Image and remote's URL aren't properties of the value, they and the case discriminator are the value. The settable properties are even more surprising, because the following two statements look like they're doing very different things, but would operate identically:

asset = .remote(url)
asset.remote = url

I can't envision a situation where I'd reach for the second one over the first or think that it feels more natural. Modifying the case of asset by mutating one of its members is even more surprising.

If you take the view that properties on an enum as modeling a projection of the cases' associated values, not the cases themselves (†), then conflating the cases with regular key paths results in a confusing API. Case paths really do need to be a separate concept to avoid forcing the wrong modeling on API designers.

(†): If all cases shared an associated value with the same label and type, you can imagine a property being used to retrieve that value or mutate it in place; an optional read-only property could represent an associated value that only exists on a subset of the cases.

9 Likes

I suppose it's a matter of perspective, but in my mind the payloads can definitely be seen as optional properties of the enum instance.

As to the setters, they could of course be made to require that you're already in the case in question. Then I again think that

asset.remote = url

is quite natural. Compare to case path syntax, which I think is this:

var asset = Asset.remote(someURL)

let remotePath = /Asset.remote

// Now this:
asset.remote = someOtherURL

// seems more natural than this:
try remotePath.modify(&asset) { $0 = someOtherURL }

// Even if we get casepath subscripts I think the syntax will be quite complex:
asset[casePath: remotePath] = someOtherURL

Maybe I have misunderstood the concept.

Taking my pseudo-syntax from earlier, we'd have something that is closer to what we expect from existing optional chaining in the language:

asset = .remote(url) // fully replace value with case
asset.remote? = url  // swap URL out of particular case
asset.remote?.deletePathExtension() // mutate if remote URL

Modeling optional-chaining semantics in property declarations is precisely what we need to describe not only case paths, but the composition of enum and struct data access.

It would also preserve the optional-access vs. non-optional writing we expect in optional-chaining:

asset.remote? = nil // 🛑 'nil' cannot be assigned to type 'URL'

@GreatApe This is a big problem with using synthesized properties as supported today:

asset.remote = nil // ✅ compiles but non-sensical
7 Likes

To be clear, I don't think anyone (including the authors of CasePaths the library :wink:) thinks Swift should have the same implementation. I think @Joe_Groff's comment about coming up with a shared syntax for case paths and case access is the main thing to figure out, thus my suggestion for repurposing the ? from optional-chaining.

Sure, I realise that the existing UX is a result of not being a built in swift feature, that's why I also mentioned the potential KeyPath like subscript syntax, which also seems too clunky.

The asset.remote? syntax is of course very similar to what I mentioned (I didn't see your post suggesting it), but to be honest I don't quite see the necessity of, or indeed logic behind, the interrogation mark. First of all because in non-chained situations the postfix interrogation mark currently means "(if) the value is present", as in pattern matching. But also if you consider the pairs:

self.view.superview // optional, but no ?
self.view.superview?.backgroundColor // chained, so we put ?

// and

asset.remote? // optional, with ?
asset.remote?.deletePathExtension() // chained, so we put ?

It sort of seems like we should have two ? in the last case.

Sure, that's what I meant by

(I seem to mostly encounter this in custom subscript situations, for example with some underlying dictionary)

By "supported to today", I presume you mean "IF we supported my suggestion"?

See Enum Case KeyPaths - #26 by stephencelis and earlier discussion.

While the trailing ? might seem superfluous, it offers a means of disambiguating from existing properties, which will help make the feature source compatible, and optional-chaining is exactly how property access and enum payload access compose, so ? gives a nice visual indicator that you are projecting into an enum case (including the Optional.some case). This distinction in syntax also calls out the fact that writability only takes non-optional values, despite readability returning an optional value.

2 Likes

Sorry to be picky, but is this really a valid argument? Sure, there is optional chaining between the enum case and any payload properties, but that is not really related to it being an enum case, it's simply because it's (necessarily) optional.

I can't see how the argument applies for enum cases, without also applying to other optional values.

Or to put it another way, there is nothing about ? that suggest "enum case".