• Stars
    star
    852
  • Rank 51,375 (Top 2 %)
  • Language
    Swift
  • License
    MIT License
  • Created about 4 years ago
  • Updated about 2 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

๐Ÿงฐ Case paths extends the key path hierarchy to enum cases.

๐Ÿงฐ CasePaths

CI Slack

Case paths extends the key path hierarchy to enum cases.

Motivation

Swift endows every struct and class property with a key path.

struct User {
  let id: Int
  var name: String
}

\User.id    // KeyPath<User, Int>
\User.name  // WritableKeyPath<User, String>

This is compiler-generated code that can be used to abstractly zoom in on part of a structure, inspect and even change it, all while propagating those changes to the structure's whole. They are the silent partner of many modern Swift APIs powered by dynamic member lookup, like SwiftUI bindings, but also make more direct appearances, like in the SwiftUI environment and unsafe mutable pointers.

Unfortunately, no such structure exists for enum cases.

enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

\UserAction.home  // ๐Ÿ›‘

๐Ÿ›‘ key path cannot refer to static member 'home'

And so it's not possible to write generic code that can zoom in and modify the data of a particular case in the enum.

Using case paths in libraries

By far the most common use of case paths is as a tool inside a library that is distributed to other developers. Case paths are used in the Composable Architecture, SwiftUI Navigation, Parsing, and many other libraries.

If you maintain a library where you expect your users to model their domains with enums, then providing case path tools to them can help them break their domains into smaller units. For example, consider the Binding type provided by SwiftUI:

struct Binding<Value> {
  let get: () -> Value
  let set: (Value) -> Void
}

Through the power of dynamic member lookup we are able to support dot-chaining syntax for deriving new bindings to members of values:

@dynamicMemberLookup
struct Binding<Value> {
  โ€ฆ
  subscript<Member>(dynamicMember keyPath: WritableKeyPath<Value, Member>) -> Binding<Member> {
    Binding<Member>(
      get: { self.get()[keyPath: keyPath] },
      set: { 
        var value = self.get()
        value[keyPath: keyPath] = $0
        self.set(value)
      }
    )
  }
}

If you had a binding of a user, you could simply append .name to that binding to immediately derive a binding to the user's name:

let user: Binding<User> = // ...
let name: Binding<String> = user.name

However, there are no such affordances for enums:

enum Destination {
  case home(HomeState)
  case settings(SettingsState)
}
let destination: Binding<Destination> = // ...
destination.home      // ๐Ÿ›‘
destination.settings  // ๐Ÿ›‘

It is not possible to derive a binding to just the home case of a destination binding by using simple dot-chaining syntax.

However, if SwiftUI used this CasePaths library, then they could provide this tool quite easily. They could provide an additional dynamicMember subscript that uses a CaseKeyPath, which is a key path that singles out a case of an enum, and use that to derive a binding to a particular case of an enum:

import CasePaths

extension Binding {
  public subscript<Case>(dynamicMember keyPath: CaseKeyPath<Value, Case>) -> Binding<Case>?
  where Value: CasePathable {
    Binding<Case>(
      unwrapping: Binding<Case?>(
        get: { self.wrappedValue[case: keyPath] },
        set: { newValue, transaction in
          guard let newValue else { return }
          self.transaction(transaction).wrappedValue[case: keyPath] = newValue
        }
      )
    )
  }
}

With that defined, one can annotate their enum with the @CasePathable macro and then immediately use dot-chaining to derive a binding of a case from a binding of an enum:

@CasePathable
enum Destination {
  case home(HomeState)
  case settings(SettingsState)
}
let destination: Binding<Destination> = // ...
destination.home      // Binding<HomeState>?
destination.settings  // Binding<SettingsState>?

This is an example of how libraries can provide tools for their users to embrace enums without losing out on the ergonomics of structs.

Basics of case paths

While library tooling is the biggest use case for using this library, there are some ways that you can use case paths in first-party code too. The library bridges the gap between structs and enums by introducing what we call "case paths": key paths for enum cases.

Case paths can be enabled for an enum using the @CasePathable macro:

@CasePathable
enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

And they can be produced from a "case-pathable" enum through its Cases namespace:

\UserAction.Cases.home      // CaseKeyPath<UserAction, HomeAction>
\UserAction.Cases.settings  // CaseKeyPath<UserAction, SettingsAction>

And like any key path, they can be abbreviated when the enum type can be inferred:

\.home as CaseKeyPath<UserAction, HomeAction>
\.settings as CaseKeyPath<UserAction, SettingsAction>

Case paths vs. key paths

Extracting, embedding, modifying, and testing values

As key paths package up the functionality of getting and setting a value on a root structure, case paths package up the functionality of optionally extracting and modifying an associated value of a root enumeration.

user[keyPath: \User.name] = "Blob"
user[keyPath: \.name]  // "Blob"

userAction[case: \UserAction.Cases.home] = .onAppear
userAction[case: \.home]  // Optional(HomeAction.onAppear)

If the case doesn't match, the extraction can fail and return nil:

userAction[case: \.settings]  // nil

Case paths have an additional ability, which is to embed an associated value into a brand new root:

let userActionToHome = \UserAction.Cases.home
userActionToHome(.onAppear)  // UserAction.home(.onAppear)

Cases can be tested using the is method on case-pathable enums:

userAction.is(\.home)      // true
userAction.is(\.settings)  // false

let actions: [UserAction] = [โ€ฆ]
let homeActionsCount = actions.count(where: { $0.is(\.home) })

And their associated values can be mutated in place using the modify method:

var result = Result<String, Error>.success("Blob")
result.modify(\.success) {
  $0 += ", Jr."
}
result  // Result.success("Blob, Jr.")

Composing paths

Case paths, like key paths, compose. You can dive deeper into the enumeration of an enumeration's case using familiar dot-chaining:

\HighScore.user.name
// WritableKeyPath<HighScore, String>

\AppAction.Cases.user.home
// CaseKeyPath<AppAction, HomeAction>

Or you can append them together:

let highScoreToUser = \HighScore.user
let userToName = \User.name
let highScoreToUserName = highScoreToUser.append(path: userToName)
// WritableKeyPath<HighScore, String>

let appActionToUser = \AppAction.Cases.user
let userActionToHome = \UserAction.Cases.home
let appActionToHome = appActionToUser.append(path: userActionToHome)
// CaseKeyPath<AppAction, HomeAction>

Identity paths

Case paths, also like key paths, provide an identity path, which is useful for interacting with APIs that use key paths and case paths but you want to work with entire structure.

\User.self              // WritableKeyPath<User, User>
\UserAction.Cases.self  // CaseKeyPath<UserAction, UserAction>

Property access

Since Swift 5.2, key path expressions can be passed directly to methods like map. Case-pathable enums that are annotated with dynamic member lookup enable property access and key path expressions for each case.

@CasePathable
@dynamicMemberLookup
enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

let userAction: UserAction = .home(.onAppear)
userAction.home      // Optional(HomeAction.onAppear)
userAction.settings  // nil

let userActions: [UserAction] = [.home(.onAppear), .settings(.purchaseButtonTapped)]
userActions.compactMap(\.home)  // [HomeAction.onAppear]

Dynamic case lookup

Because case key paths are bona fide key paths under the hood, they can be used in the same applications, like dynamic member lookup. For example, we can extend SwiftUI's binding type to enum cases by extending it with a subscript:

extension Binding {
  subscript<Member>(
    dynamicMember keyPath: CaseKeyPath<Value, Member>
  ) -> Binding<Member>? {
    guard let member = self.wrappedValue[case: keyPath]
    else { return nil }
    return Binding<Member>(
      get: { self.wrappedValue[case: keyPath] ?? member },
      set: { self.wrappedValue[case: keyPath] = $0 }
    )
  }
}

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

struct ItemStatusView: View {
  @Binding var status: ItemStatus

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

Note The above is a simplified version of the subscript that ships in our SwiftUINavigation library.

Computed paths

Key paths are created for every property, even computed ones, so what is the equivalent for case paths? Well, "computed" case paths can be created by extending the case-pathable enum's AllCasePaths type with properties that implement the embed and extract functionality of a custom case:

@CasePathable
enum Authentication {
  case authenticated(accessToken: String)
  case unauthenticated
}

extension Authentication.AllCasePaths {
  var encrypted: AnyCasePath<Authentication, String> {
    AnyCasePath(
      embed: { decryptedToken in
        .authenticated(token: encrypt(decryptedToken))
      },
      extract: { authentication in
        guard
          case let .authenticated(encryptedToken) = authentication,
          let decryptedToken = decrypt(token)
        else { return nil }
        return decryptedToken
      }
    )
  }
}

\Authentication.Cases.encrypted
// CaseKeyPath<Authentication, String>

Case studies

  • SwiftUINavigation uses case paths to power SwiftUI bindings, including navigation, with enums.

  • The Composable Architecture allows you to break large features down into smaller ones that can be glued together user key paths and case paths.

  • Parsing uses case paths to turn unstructured data into enums and back again.

Do you have a project that uses case paths that you'd like to share? Please open a PR with a link to it!

Community

If you want to discuss this library or have a question about how to use it to solve a particular problem, there are a number of places you can discuss with fellow Point-Free enthusiasts:

Documentation

The latest documentation for CasePaths' APIs is available here.

Credit and thanks

Special thanks to Giuseppe Lanza, whose EnumKit inspired the original, reflection-based solution this library used to power case paths.

Interested in learning more?

These concepts (and more) are explored thoroughly in Point-Free, a video series exploring functional programming and Swift hosted by Brandon Williams and Stephen Celis.

The design of this library was explored in the following Point-Free episodes:

video poster image

License

All modules are released under the MIT license. See LICENSE for details.

More Repositories

1

swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
Swift
10,990
star
2

swift-snapshot-testing

๐Ÿ“ธ Delightful Swift snapshot testing.
Swift
3,559
star
3

isowords

Open source game built in SwiftUI and the Composable Architecture.
Swift
2,515
star
4

swiftui-navigation

Tools for making SwiftUI navigation simpler, more ergonomic and more precise.
Swift
1,654
star
5

swift-dependencies

A dependency management library inspired by SwiftUI's "environment."
Swift
1,322
star
6

swift-tagged

๐Ÿท A wrapper type for safer, expressive code.
Swift
1,289
star
7

swift-overture

๐ŸŽผ A library for function composition.
Swift
1,115
star
8

pointfreeco

๐ŸŽฌ The source for www.pointfree.co, a video series on functional programming and the Swift programming language.
Swift
1,054
star
9

episode-code-samples

๐Ÿ’พ Point-Free episode code.
Swift
922
star
10

swift-nonempty

๐ŸŽ A compile-time guarantee that a collection contains a value.
Swift
817
star
11

swift-parsing

A library for turning nebulous data into well-structured data, with a focus on composition, performance, generality, and ergonomics.
Swift
802
star
12

swift-custom-dump

A collection of tools for debugging, diffing, and testing your application's data structures.
Swift
766
star
13

swift-html

๐Ÿ—บ A Swift DSL for type-safe, extensible, and transformable HTML documents.
Swift
723
star
14

combine-schedulers

โฐ A few schedulers that make working with Combine more testable and more versatile.
Swift
671
star
15

swift-web

๐Ÿ•ธ A collection of Swift server-side frameworks for handling HTML, CSS, routing and middleware.
Swift
477
star
16

swift-identified-collections

A library of data structures for working with collections of identifiable elements in an ergonomic, performant way.
Swift
476
star
17

swift-prelude

๐ŸŽถ A collection of types and functions that enhance the Swift language.
Swift
456
star
18

swift-perception

Observable tools, backported.
Swift
399
star
19

swift-validated

๐Ÿ›‚ A result type that accumulates multiple errors.
Swift
389
star
20

swift-url-routing

A bidirectional router with more type safety and less fuss.
Swift
321
star
21

swift-concurrency-extras

Useful, testable Swift concurrency.
Swift
267
star
22

swift-gen

๐ŸŽฑ Composable, transformable, controllable randomness.
Swift
261
star
23

swift-clocks

โฐ A few clocks that make working with Swift concurrency more testable and more versatile.
Swift
229
star
24

swift-enum-properties

๐Ÿค Struct and enum data access in harmony.
Swift
198
star
25

xctest-dynamic-overlay

Define XCTest assertion helpers directly in your application and library code.
Swift
197
star
26

swift-macro-testing

Magical testing tools for Swift macros.
Swift
192
star
27

syncups

A rebuild of Appleโ€™s โ€œScrumdingerโ€ application using modern, best practices for SwiftUI development.
Swift
153
star
28

composable-core-location

A library that bridges the Composable Architecture and Core Location.
Swift
100
star
29

vapor-routing

A bidirectional Vapor router with more type safety and less fuss.
Swift
84
star
30

swift-html-vapor

๐Ÿ’ง Vapor plugin for type-safe, transformable HTML views.
Swift
82
star
31

swift-playground-templates

๐Ÿซ A collection of helpful Xcode playground templates.
Makefile
80
star
32

pointfreeco-server

Point-Free server code.
39
star
33

swift-boundaries

๐Ÿฃ Functional core, imperative shell.
Swift
27
star
34

composable-core-motion

A library that bridges the Composable Architecture and Core Motion.
Swift
26
star
35

swift-quickcheck

๐Ÿ An implementation of QuickCheck in Swift.
Swift
24
star
36

swift-algebras

Algebraic laws bundled into concrete data types.
19
star
37

swift-either

For those times you want A or B!
Swift
19
star
38

swift-parser-printer

โ†”๏ธ Parsing and printing
Swift
14
star
39

swift-html-kitura

โ˜๏ธ Kitura plugin for type-safe, transformable HTML views.
Swift
13
star
40

homebrew-swift

Ruby
2
star
41

swift-bugs

2
star
42

Ccmark

Swift
1
star