Enum Case KeyPaths

This is great! Thanks for your work on this so far @Alejandro!

As maintainers of CasePaths, @mbrandonw and I are definitely excited about any movement in this part of Swift, and we thought we would also be able to weigh in a bit on how case paths are used today.

The proposed getter functionality is definitely a step forward, but we wonder if it's possible to prioritize exposing the"embed" functionality in this pitch. We maintain a few popular projects that depend on the ability to embed values in enums abstractly, and they contain many real-world motivated uses:

  • swift-composable-architecture: An opinionated library for building/composing applications out of small units. Case paths are used in a few ways, but mostly to achieve this composition of features in a lightweight operation.

  • swiftui-navigation: A library that allows SwiftUI applications to model their navigation state in enums, which are a great data type for describing a list of destinations a view may navigate to. It uses case paths to derive SwiftUI bindings to these enum values held in state.

  • swift-parsing: A parsing library that can also generate a “printer” for “un-parsing,” or reversing a parsed output back into an input. Case paths are what power this bidirectionality for enums that appear in parsed output.

…and many third party dependencies that use CasePaths for both extract and embed functionality.

On top of the groundwork laid out so far, a new subclass in the key path hierarchy could potentially be introduced with the following functionality:

class CasePath<Root, Value>: KeyPath<Root, Value?> {
  func embed(_ value: Value) -> Root
}

With this in place, we could sunset our library, and downstream libraries could embrace native support, instead.

Also, one thing to note is that our library (and downstream libraries) can unfortunately not leverage any of the great work in this proposal so far. Today we resort to all kinds of runtime reflection in order to dynamically create case paths, and sadly there are still some edge cases that we have not yet figured out. But I don’t think we can replace any of that work with enum case key paths unless embed functionality is also exposed.

We’ve done a lot of thought in this problem space in Swift in general, so we are more than happy to collaborate further on various nitty-gritty details and future directions!

28 Likes

I think there's also at least one intermediate subclass between read-only KeyPath and CasePath. It is true that enum case components have three key operations:

  • project the value (if it exists), which this proposal provides
  • attempt to rewrite the payload in-place in an existing value
  • construct an entire new value of the enum, using the given case and a payload

However, if you have a composition of a case component followed by a struct property or tuple element access on the payload, such as \Enum.casePayload?.foo, then you can only do the first two; since the key path only refers to a partial payload, you can't create an entire new value without an existing value to update. So that suggests to me that you'd have:

// names chosen arbitrarily, not being seriously proposed as is
class ConditionallyWritableKeyPath<Root, Value>: KeyPath<Root, Value?> {
  func trySet(in: inout Root, to: Value) throws
}

class CasePath<Root, Value>: ConditionallyWritableKeyPath<Root, Value> {
  func embed(_ value: Value) -> Root
}
4 Likes

Yup! We've called this thing an OptionalPath (or WritableOptionalPath) in our explorations. And there's probably room for a ReferenceWritableOptionalPath as well.

There are plenty of details to figure out for if/how to get this functionality more generally into property declaration syntax, and excited for the potential!

7 Likes

I love the idea of accessing cases via key paths.

The proposal should address the ambiguity of the following scenario, extending the first example from the proposal:

enum Color {
  case blue
  case generic(String)

  var blue: Bool {
    if case .blue = self { return true }
    return false
  }
}

let kp = \Color.blue

What is the type of kp? Presumably it must be KeyPath<Color, Bool>, because otherwise it's a source-breaking change. Is it possible to explicitly specify the type of kp as KeyPath<Color, Void?> to get the other meaning?

In general, this extension to KeyPath literals is weird, because it's treating a static member as an instance member. Both blue and generic are constructors, which means they are essentially static methods of the type. Is there any other situation where a KeyPath literal treats a static member as an instance member?

Here's an alternate spelling to consider, that resolves the ambiguity when there is an instance property with the same name as a case:

let kp1 = \Color.blue // KeyPath<Color, Bool>, refers to computed property `blue`
let kp2 = \Color.case blue // KeyPath<Color, Void?>, refers to case `blue`
6 Likes

I don't hate this. foo.case blue isn't so bad as an expression for accessing the optionally-present payload of blue in an expression either.

1 Like

I really like this, especially compared to discussion that's been had in the past about synthesizing properties directly on the enum type itself to surface the payloads. Using case (or some other token) as a sigil nicely distinguishes the structural value of the cases from the public API vended by the type, which may not want to represent the actual value in precisely that way (nor be forced to by compiler synthesis).

This proposal is just a simple addition to our current keypath capabilities, but it's been pointed out before to add the ability to reference.... functions

I've actually been working on a pitch to do just this; it's really cool to see that someone else is at the implementation stage for part of it. Though it looks like it's a lot harder than I thought, if these aren't all treated as functions by the compiler.

One question that I have is how you'll deal with Enums that don't have a payload that's Hashable? It seems like the standard library needs a new class that has some of the same capabilities as a KeyPath, but does not conform to Hashable; my rough draft calls this an Invocation.

1 Like

Looks great!

I'm curious about how it works with @dynamicMemberLookup.

@dynamicMemberLookup
enum Barcode {
    case upc(Int, Int, Int, Int)
    case qrCode(String)

    subscript<T>(dynamicMember keyPath: KeyPath<Self, T>) -> T {
        return self[keyPath: keyPath]
    }
}

let barcode = Barcode.qrCode("sample")
print(barcode.qrCode)  // Optional("sample")
print(barcode.upc)     // nil

I believe the capability to get associated values as Optional values is what is discussed for a long time in some thread. While we still must write boilerplate of dynamic member lookup, the implementation of such functionality will be much easier than ever with this feature.

2 Likes

I'm conflicted about the consistency and inconsistency of this.

On the one hand, it consolidates the mental model for case keypaths and dot-syntax case access.

On the other hand, having spaces in a dot-syntax chain is utterly jarring and AFAIK a first from any language I've seen (except maybe Swift itself with mid-chain trailing closures, which is still jarring to some to this day).

One thing I thought of is using backticks to isolate the expression as a single semantic unit:

let kp2 = \Color.`case blue`

But this only seems to make things more convoluted, not less.

1 Like

Is it a design goal to not introduce a new KeyPath subclass for this functionality? If we had that then disambiguation could be done using the idiomatic as coercion, no? This would already work when the case path and instance property have different types anyway.

3 Likes

All these should be considered together to ensure we don’t get any unexpected ambiguities. And dynamicMemeberLookup is so common that it should probably not be used on the enum itself, but provided by the compiler as Joe mentions in the beginning.

Also, wouldn’t the no-payload case, that returns Void? fail the Hashable conformance criterion?

1 Like

An enum case key path refers only to the case declaration itself, and it doesn't hold a value of the payload. So it shouldn't matter whether the payload itself is hashable, since we just need a unique ID for the enum case to identify it inside the key path.

9 Likes

Ah, I see now. I thought this let you write \.generic(“some text”), my mistake. Thanks for explaining.

I think we'll want to introduce new subclasses to support additional operations that make sense on case key paths, as @stephencelis noted. However, it still also makes sense to support case key paths as read-only keypaths for older OSes, which wouldn't have those new subclasses, and being able to read a case with a key path is also still a useful extension that doesn't prevent us from adding case key path subclasses in the future.

7 Likes

I would find it unfortunate for the static constructor mental model (such as static member protocol witnessing) to be confused by accessing enum associated values as if they were instance members, even though they cannot be accessed as such directly (not using a key path).

I would rather like to see them be a part of a consistent static member lookup syntax, even if key paths to other static members comes later.

My first thought in trying to stick close to other existing syntax would be using self, something like \SomeEnum.self.caseOrStaticMember.

However, I wonder about a more concise syntax, specifically, using .. in key paths to refer to static members. This is not only concise, but has a strong link to the ‘parent directory’ shortcut any developer who is familiar with basic command line usage knows (e.g. cd ..).

Example:

enum Side {
  case drink(Drink)
  case fries(FryType)
  static var random: Self { … }
}

let side: Side = order.side // Imagine this came from somewhere.

// Get associated value
let drink: Drink? = side[keypath: \..drink]

(I haven’t fully thought through how keypaths would interact with static members in general, since they wouldn’t need to be applied to any particular instance, but I thought I would share this anyways in case it’s an idea worth exploring.)

1 Like

I think this interaction of Enum.enumCase with Enum.staticProperty should be added as a future direction. I have another suggestion for how this could be realized. We’d first have to allow function and static properties (and static functions as the combination of the two):

enum Enum { case a, b(Int) }

\Enum.Type.a // KeyPath<Enum.Type, Enum>

Note that the key path literal is Enum.Type and not .self because self would refer the self of an Enum instance. Depending on how we design functions, enums would payloads could look one of two ways (or both):

\Enum.Type.b // KeyPath<Enum.Type, (Int) -> Enum>
\Enum.Type.b(1) // KeyPath<Enum.Type, Enum>

If we are to choose the second method we’re arguments are used in the key paths, we’d probably require an intermediate non-Hashable key path type to support all enum cases.

1 Like

The space doesn't bother me, but here's an alternative that avoids it. Just as every value has a subscript(keyPath k:), every enum value could have a subscript(case c:). Its signature would be something like this:

extension (all enum types) {
    subscript<A...>(case c: (A...) -> Self) -> (A...)? { get }
}

You could then use it to check if an instance of an enum is a particular case, access that case's associated values, and construct a key path to that case:

let color: Color = ...

let isBlue = color[case: .blue] != nil
let isGeneric = color[case: .generic] != nil

let blueness: Void? = color[case: .blue]
let genericity: String? = color[case: .generic]

let kpBlue: KeyPath<Color, Void?> = \.[case: .blue]
let kpGeneric: KeyPath<Color, String?> = \.[case: .generic]

(Swift won't currently infer that .blue means Color.blue or that .generic means Color.generic in the way I'm using above, but we'd want to make it do so in this context.)

6 Likes

Even if we don't have a new KeyPath for the read-only case paths, I think it would be nice if we didn't have to come up with new syntax solely for the disambiguation case...

Type disambiguation seems like it should suffice for all cases where the instance member doesn't have the same type as the (optional) enum payload:

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

Now, if we had an additional property:

extension Color {
  var generic: String? { ... }
}

type disambiguation doesn't help us. However, I'm not sure that's such a problem. For source compatibility reasons, we'd want to continue preferring the instance member key path interpretation of \Color.generic. But that's only an issue if the implementation of the instance member E.generic doesn't already implement the case path functionality. I expect that to be exceedingly rare when we have an instance member on an enum which a) has the same name as an enum case and b) has the same type as the (optional) enum payload.

Even then, a user who needs to get at case path semantics for such an unusual enum is able to implement it themselves:

extension E {
  var casePathGeneric: String? {
    guard case let .generic(let s) = self else { return nil }
    return s
  }
}

let kp = \Color.casePathGeneric

So I'm a bit skeptical of the need for us to invent new syntax for this situation, at least until we've seen whether this is an ergonomic issue in practice.

3 Likes

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