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 TextField
s and Toggle
s, 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.