Enum Case Key Paths: An Update

There has been a lot of discussions about better supporting key paths for enums on the forums (here, here, here, here, here, here, here, and more…), but we wanted to share some work we have done to make things as nice as we can using Swift macros.

We have been able to employ macros to generate actual key paths for enum cases, which we call “case key paths.” While not quite as good as first class language support, we thought it might be interesting to share the full case key path functionality and use cases, including dynamic "case" lookup.

We have defined a @CasePathable macro in our CasePaths package, which automatically generates actual key paths to an enum's cases:

@CasePathable
enum Destination {
  case activity(ActivityModel)
  case settings(SettingsModel)
}

\Destination.Cases.activity  // CaseKeyPath<Destination, ActivityModel>

These key paths can be used with enum values via familiar subscripting:

let destination = Destination.activity(ActivityModel())
destination[case: \.activity]  // Optional(ActivityModel)
destination[case: \.settings]  // nil

But are also extended with some extra functionality, including callAsFunction for embedding new payloads:

let activityPath = \Destination.Cases.activity

activityPath(ActivityModel())  // Destination.activity(ActivityModel())

Otherwise these case key paths can be used in all the same ways as regular key paths, such as writing generic algorithms over the “shape” of enums, and even dynamic member lookup.

Case study: Easy enum case checking and extraction

By applying the @CasePathable macro to an enum it gets access to an is method for checking whether or not an enum value matches a particular case:

let destination = Destination.activity(ActivityModel())

if destination.is(\.activity)  // true

And further, an enum can opt into making “getter” properties available for each case, which extract the payload from an enum via @dynamicMemberLookup and a default dynamic member subscript:

@CasePathable
@dynamicMemberLookup
enum Destination {
	…
}

let destination = Destination.activity(ActivityModel())
destination.activity  // Optional(ActivityModel)
destination.settings  // nil

This can clean up statement-heavy code with a syntax folks have been asking for for a long time on these forums:

let destinations: [Destination] = […]
destinations.compactMap(\.activity)  // [ActivityModel]

Case study: SwiftUI Bindings

Case key paths make it possible to drive SwiftUI navigation from an enum rather than a bunch of independent optionals. For example, if a view can navigate to two different, mutually exclusive features, it would be best to model this like so:

struct FeatureView: View {
  @State var destination: Destination?

  enum Destination {
    case activity(ActivityModel)
    case settings(SettingsModel)
  }

  …
}

But it can be difficult to construct bindings to each case of the Destination enum so that you can use the sheet(item:), popover(item:) (and more) view modifiers.

But if you an enum is annotated with @CasePathable

@CasePathable
enum Destination {
	// ...
}

…then we can leverage “dynamic case lookup” on bindings to allow them to be transformed into the shape SwiftUI’s existing view modifiers expect via dot-chaining syntax:

.sheet(item: self.$destination.activity) { model in 
  ActivityView(model: model)
}
.popover(item: self.$destination.settings) { model in 
  SettingsView(model: model)
}

It is also possible to drive form controls, like TextFields and Toggles, using a String or Bool that would otherwise be trapped inside an enum case:

@CasePathable
enum Status {
  case inStock(quantity: Int)
  case outOfStock(isOnBackOrder: Bool)
}

@Binding var status: Status

switch self.item.status {
case .inStock:
  $status.inStock.map { $quantity in
    Section {
      Stepper("Quantity: \(quantity)", value: $quantity)
      Button("Mark as sold out") {
        status = .outOfStock(isOnBackOrder: false)
      }
    } header: { Text("In stock") }
  }
case .outOfStock:
  $status.outOfStock.map { $isOnBackOrder in
    Section {
      Toggle("Is on back order?", isOn: $isOnBackOrder)
      Button("Is back in stock!") {
        status = .inStock(quantity: 1)
      }
    } header: { Text("Out of stock") }
  }
}

If you want to try any of this out, our SwiftUINavigation library has been updated to define dynamic member lookup on Binding with a CaseKeyPath.

Case study: Composing App Features

The main impetus for us developing case paths nearly 4 years ago was our Composable Architecture library, which provides a structured way of defining features and composing them together. Features use enums to enumerate all the possible user actions in an application, and these enums nest through layers of parent/child domains, and case paths were necessary to write code that could abstractly glue these features together.

We have also updated this library to use case key paths, which allows one to compose features together by isolating child state and actions using simple and familiar key path syntax:

 Reduce { state, action in 
   // ...
 }
-.ifLet(\.child, action: /Action.child) {
+.ifLet(\.child, action: \.child) {
   ChildFeature()
  }

This allows us to take advantage of all the nice things native key paths give us, like Xcode autocomplete and type inference.


We hope achieving this syntax and showing these use cases further motivates the eventual inclusion of case key paths into the language, and we hope folks come up with more interesting use cases to share.

37 Likes

Truly amazing ergonomic improvements all around. Feels the closest it can possibly get to the ideal API without actually being built into Swift itself. I still can't get over how elegantly @CasePathable interacts with @dynamicMemberLookup; composable Swift at its finest :ok_hand:

8 Likes

Amazing work on CasePaths to bring it to this point. I see that the pitch discussion stalled two years ago. Are there any more recent status updates on the pitch?

I'm not aware of any work being done on this front by the core team, but maybe a member could give us an update. The last I'm aware of was @Alejandro's open pull request from around the same time, which introduces the "extract" functionality of case paths. Without "embed" functionality, though, the majority of the tools we've built around case paths are not possible to implement.

4 Likes