Enum Case KeyPaths

Hi Evolution, I have a simple pitch to start allowing read-only keypaths to refer to enum cases below. Please let me know what you think!

Enum Case KeyPaths

Introduction

I propose to allow keypaths to reference enum cases as components. The result of accessing this keypath is either an optional single element type, an optional tuple of the associated value, or, in the case of an empty case, an optional void.

Motivation

Currently in Swift, keypaths can refer to stored properties, computed properties, and applied subscripts (among other components), but one cannot use keypaths to reference an enum case. Say you want to get the associated value out of an enum case and perform some operations on it. You would need to manually write out the switch statement or perhaps a guard like the following:

enum Color {
  case blue
  case generic(String)
}

func genericFirstLetter(of color: Color) -> Character? {
  guard case .generic(let str) = color else {
    return nil
  }

  return str.first
}

This makes working with enums somewhat awkward. You can make helpers to make this kind of operations easier to work with, but if you have a lot of cases with associated values that you care about, this can easily become a lot of boilerplate.

extension Color {
  var genericValue: String? {
    guard case .generic(let str) = self else {
      return nil
    }

    return str
  }
}

func genericFirstLetter(of color: Color) -> Character? {
  color.genericValue?.first
}

Proposed solution

Allow enum cases to now be referenced by keypath components.

enum Color {
  case generic(String)
}

func genericFirstLetter(of color: Color) -> Character? {
  color[keyPath: \.generic?.first]
}

let pink = Color.generic("Pink")

print(genericFirstLetter(of: pink)) // Optional("P")

Detailed design

What's being proposed is a read-only keypath to an enum case's payload (if it has one). If referencing an enum case with no payload, the result is Void?. If the case does have a payload, its result is Payload? where Payload is either a single type (for single element payloads due to no 1 element tuples) or a tuple of the payload elements. Indirect cases are also supported.

Referring to an enum case with a payload and not specifying the argument list is ok when there's only a single case with that name.

enum Color {
  case generic(String)
}

let _: KeyPath<Color, String?> = \Color.generic // ok

However, you cannot do this when there are multiple cases with the same name but a different number of arguments, labels, etc.

enum Color {
  case generic(hue: Int)
  case generic(String)
  case generic(String, Int)
}

// error: ambiguous
let _ = \Color.generic

// Refers to Color.generic(String)
let _: KeyPath<Color, String?> = \Color.generic(_:)

// Refers to Color.generic(String, Int)
let _: KeyPath<Color, (String, Int)?> = \Color.generic(_:_:)

// Refers to Color.generic(hue: Int)
let _: KeyPath<Color, Int?> = \Color.generic(hue:)

Enum cases cannot currently have the same name and share the same number of arguments and argument labels.

enum Type {
  case void
  case void(Void) // error: redeclaration of void

  case string(String)
  case string(Substring) // error: redeclaration of string
  case string(slice: Substring) // ok
}

So it's impossible to have a scenario where \Enum.case(_:) refers to potentially 2 enum cases. It will always refer to a single case.

One can also refer to a specific tuple element by using the argument label instead if they desire.

enum Color {
  case generic(name: String, hue: Int)
}

let _ = \Color.generic?.name
let _ = \Color.generic?.hue

However, if a case has a named single element, you cannot refer to it by name.

enum Flower {
  case unknown(name: String)
}

let _ = \Flower.unknown?.name // error

because the value returned is the named argument itself.

When referring to the enum case Optional.some, we'll return the already existing optional chain component, so keypaths like \String?.some?.first will be transformed to \String?.?.first.

Source compatibility

This has no effect source compatibility because one cannot reference an enum case as a keypath component today in source.

Effect on ABI stability

This requires an ABI addition to keypath patterns, but the internal representation of keypath is not affected because it is not ABI.

We can back deploy this feature by emitting a less efficient representation for these on older OSes treating them as if they were computed properties, but on newer versions we can benefit from the simplified representation.

Effect on API resilience

None.

Alternatives considered

Use Bool for empty cases

Another representation we could use for empty enum cases is Bool. This would certainly be easier to use when say branching on the value of this keypath like the following:

enum Color {
  case red
  case green
  case blue
}

func paint(with color: Color) {
  if color[keyPath: \.blue] {
    // ...
  }
}

however it'd probably be easier to just compare the enum case directly by color == .blue instead of using the keypath's result.

I believe that going with the Void? approach makes all enum cases more consistent in that the result is an optional value vs. some being optional some being a Bool.

Future Directions

Mutable keypaths to enum cases

As discussed in the detailed design, this only covers read-only keypaths and it might be useful to actually set specific values within an enum's payload.

enum Color {
  case generic(String)
}

// Something like the following type:
// OptionalWritableKeyPath<Color, String>
let genericKP = \Color.generic

var pink = Color.generic("Pink")

pink[keyPath: genericKP] = "Purple"

print(pink) // Color.generic("Purple")

The issue with this is that you can provide this keypath to a non Color.generic and the set essentially fizzles out and does nothing. It would be useful to introduce a new KeyPath subclass that gives us the specific semantics for this operation allowing us to read as Value? but write into as Value.

This is not being proposed due to needing a bit more designing and implementation to fully grasp what this new keypath means. Starting with read-only keypaths to enum cases is a simple step forward and simple usability win.

More keypaths to other things

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 static members, functions (applied and unapplied), initializers, etc. It makes a lot of sense to add those features to the language, but it doesn't all need to be at once. We can gradually work towards each and every one of them.

53 Likes

This is great to see! A couple things come to my mind:

  • Up until now, the components in a key path have been things that can also be projected out of a value of the root type. Currently, you can't access an enum case as an instance member, although there have been proposals to present enum cases as some form of instance member. At the risk of entangling this feature with another, it would be interesting to consider what we think that feature would look like, and try to align the spelling and behavior of key path case components with that eventual feature, both in spelling and in details such as whether empty cases produce Bool or Void?. Personally, I'm fine with enumValue.caseName working to access the value of caseName wrapped in an Optional, or return a bool, although some folks have also proposed mangling the name into something like asCaseName or isCaseName.

  • The way that ? components compose with preceding enum case components is an interesting thing to consider when thinking about conditionally-mutable key path subclasses ("prisms" as they like to call them in the functional programming Lens world) in the future. Thinking of \Enum.caseName?.foo as two different components in this situation is kind of weird, since the conditionality of caseName's payload existing is implied by it being an enum case, and ? doesn't really do anything on its own besides allowing you to further project foo or other parts of the case payload when it's present. It seems to me that ? should be considered to be part of the preceding case component, or if it appears after a non-case component, effectively equivalent to a case component referencing Optional.some (albeit preserving its current special case representation for ABI compatibility).

9 Likes

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