The Case for Case Paths: Properties

Episode #88 • Jan 27, 2020 • Subscriber-Only

We’ve now seen that it’s possible to define “case paths”: the enum equivalent of key paths. So what are their features? Let’s explore a few properties of key paths to see if there are corresponding concepts on case paths.

Properties
Introduction
00:05
Appending paths
00:57
Introducing the .. operator
07:46
Identity paths
11:43
Re-introducing the ^ operator
14:31
Next time: case paths for free
22:20

Unlock This Episode

Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.

Introduction

So creating a case path is quite easy, but there are a lot of things not completely right with this code snippet. First, it is not right to pollute the Result namespace with these static vars, it would be better to have a different place to store them. Also, there is some boilerplate involved in creating these case paths. We see it here with this if case let stuff when implementing the extract, and basically every case path we create will look exactly like this, and after awhile it’s going to be a pain to have to write over and over.

We are going to solve both of those problems soon, but first we want to explore some of the properties of case paths and show the similarities with key paths. There are some operations that the Swift standard library provides for key paths, and we of course would expect that there is some version of those operations for case paths too.

This episode is for subscribers only.

Subscribe to Point-Free

Access this episode, plus all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Exercises

  1. Case paths don’t only need to focus on a single case of an enum. They can also focus on multiple cases of an enum, we just have to do a little bit of manual work first.

    Consider the following enum:

    enum AppAction {
      case activity(ActivityAction)
      case dashboard(DashboardAction)
      case profile(ProfileAction)
    }
    
    enum ActivityAction {}
    enum DashboardAction {}
    enum ProfileAction {}
    

    Write a case path that can extract an activity or profile action from an app action, but not a dashboard action. Compare this to how one would write a computed property that focuses on two struct fields at the same time.

    Solution

    To define a case path that can select several cases out of an enum, we need a type that can describe those cases. We can define it manually:

    enum ActivityOrProfileAction {
      case activity(ActivityAction)
      case profile(ProfileAction
    }
    

    And then we can create a case path from AppAction to this new container.

    extension CasePath
      where Root == AppAction,
      Value == ActivityOrProfile {
    
      static let activityOrProfile = CasePath(
        embed: {
          switch $0 {
          case let .activity(action): return .activity(action)
          case let .profile(action): return .profile(action)
          }
      },
        extract: {
          switch $0 {
          case let .activity(action): return .activity(action)
          case let .profile(action): return .profile(action)
          default: return nil
          }
      })
    }
    

    You could even use the Either type for this kind of thing!

    CasePath<AppAction, Either<ActivityAction, ProfileAction>>
    
  2. Every computed property on a type (structs, enums and classes) is given a key path for free by the Swift compiler. For example:

    struct State {
      var count: Int
      var favorites: [Int]
    
      var isFavorite: Bool {
        get { self.favorites.contains(self.count) }
        set {
          newValue
            ? self.favorites.removeAll(where: { $0 == self.count })
            : self.favorites.append(self.count)
        }
      }
    }
    
    \State.isFavorite // WritableKeyPath<State, Bool>
    

    The isFavorite computed property is given a WritableKeyPath, even though it is not a stored field on the struct.

    What is the equivalent concept for case paths? Theorize what a “computed case” syntax could look like in Swift.

  3. Although enums are a great source for case paths, it is not the only situation in which case paths can occur. At its core, case paths only express the idea of being able to try to extract some data from a value, and the ability to construct a value from that data.

    Implement the following case paths. A natural place to hold these case paths is as static variables on CasePath with Root and Value suitably constrained.

    • int: CasePath<String, Int>
    • uuid: CasePath<String, UUID>
    • literal: (String) -> CasePath<String, String>:
      let blobCasePath = CasePath.literal("Blob")
      blob.extract("Blob")     // "Blob"
      blob.extract("Blob Jr.") // nil
      blob.embed("Blob Sr.")   // "Blob"
      
    • first: CasePath<[A], A>
    • first: (where: (A) -> Bool) -> CasePath<[A], A>
    • key: (K) -> CasePath<[K: V], V>
    • rawValue: CasePath<R.RawValue, R> where R: RawRepresentable
    Solution
    extension CasePath where Root == String, Value == Int {
      static let int = CasePath(
        embed: String.init,
        extract: Int.init
      )
    }
    
    import Foundation
    extension CasePath where Root == String, Value == UUID {
      static let uuid = CasePath(
        embed: { $0.uuidString },
        extract: UUID.init(uuidString:)
      )
    }
    
    extension CasePath where Root == String, Value == String {
      static func literal(_ string: String) -> CasePath {
        CasePath(
          embed: { _ in string },
          extract: { $0 == string ? string : nil }
        )
      }
    }
    
    extension CasePath
      where Root: RangeReplaceableCollection,
      Value == Root.Element {
    
      static var first: CasePath {
        CasePath(
          embed: { Root([$0]) },
          extract: { $0.first }
        )
      }
    
      static func first(
        where p: @escaping (Value) -> Bool
      ) -> CasePath {
        CasePath(
          embed: { Root([$0]) },
          extract: { $0.first(where: p) }
        )
      }
    }
    
    extension CasePath {
      static func key<Key>(
        _ key: Key
      ) -> CasePath where Root == [Key: Value] {
        CasePath(
          embed: { [key: $0] },
          extract: { $0[key] }
        )
      }
    }
    
    extension CasePath
      where Value: RawRepresentable,
      Root == Value.RawValue {
    
      static var rawRepresentable: CasePath {
        CasePath(
          embed: { $0.rawValue },
          extract: Value.init(rawValue:)
        )
      }
    }
    
  4. As we’ve seen, both key paths and case paths are composable: they both support an appending(path:) operation for combining key paths with key paths or case paths with case paths. The next few exercises will explore if it is possible to combine key paths with case paths in various ways.

    First: is it possible to implement a function that appends a key path with a case path to return a new case path?

    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> CasePath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    

    Or a function that appends a case path with a key path to return a new case path?

    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> CasePath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    
    Solution

    It is not possible to implement these functions. While it is possible to define the extract functions, it is impossible to define the embed functions because the key path’s root is never available.

    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> CasePath<Root, AppendedValue> {
        return CasePath<Root, AppendedValue>(
          extract: { root in path.extract(self.get(root)) },
          embed: { appendedValue in fatalError() }
        )
      }
    }
    
    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> CasePath<Root, AppendedValue> {
        CasePath<Root, AppendedValue>(
          extract: { root in
            guard let value = self.extract(root) else { return nil }
            return path.get(value)
        },
          embed: { appendedValue in fatalError() })
      }
    }
    
  5. Is it possible to write a function that appends a key path with a case path to return a new key path?

    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    

    How about a function that appends a case path with a key path to return a new key path?

    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    
    Solution

    It is not possible to implement these functions, either, because it is not possible to implement the returned key path’s getter. Each get function must return a non-optional AppendedValue, but case path extraction requires this value to be optional.

    extension _WritableKeyPath {
      func appending<AppendedValue>(path: CasePath<Value, AppendedValue>) -> _WritableKeyPath<Root, AppendedValue> {
        return _WritableKeyPath<Root, AppendedValue>(
          get: { root in
            let value = self.get(root)
            let appendedValue = path.extract(value)
            return appendedValue // 🛑
        },
          set: { root, appendedValue in
            self.set(&root, path.embed(appendedValue))
        }
        )
      }
    }
    
    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue> {
        _WritableKeyPath<Root, AppendedValue>(
          get: { root in
            guard let value = self.extract(root) else {
              return nil // 🛑
            }
            let appendedValue = path.get(value)
            return appendedValue
        },
          set: { root, appendedValue in
            guard var value = self.extract(root) else { return }
            path.set(&value, appendedValue)
            root = self.embed(value)
        }
        )
      }
    }
    
  6. Implement a function that appends a key path with a case path and returns a new key path with an optional value.

    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue?> {
        fatalError("unimplemented")
      }
    }
    

    Also implement a function that appends a case path with a key path and returns a new key path with an optional appended value.

    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue?> {
        fatalError("unimplemented")
      }
    }
    
    Solution
    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue?> {
        _WritableKeyPath<Root, AppendedValue?>(
          get: { root in path.extract(self.get(root)) },
          set: { root, appendedValue in
            guard let appendedValue = appendedValue else { return }
            self.set(&root, path.embed(appendedValue))
        }
        )
      }
    }
    
    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> _WritableKeyPath<Root, AppendedValue?> {
        _WritableKeyPath<Root, AppendedValue?>(
          get: { root in
            guard let value = self.extract(root) else { return nil }
            return path.get(value)
        },
          set: { root, appendedValue in
            guard var value = self.extract(root) else { return }
            guard let appendedValue = appendedValue else { return }
            path.set(&value, appendedValue)
            root = self.embed(value)
        }
        )
      }
    }
    
  7. Given the previous exercise’s operations that append key paths with case paths and case paths with key paths, what happens when you try to compose the following?

    // KP = _WritableKeyPath
    // CP = CasePath
    
    KP<A, B> + CP<B, C> + KP<C, D>
    

    Can key paths and case paths compose more than one time?

    Solution

    Because this form of composition makes the appended Value parameter optional, it is impossible to further append paths without introducing new operations that flatten this optional.

  8. There exists another path type that avoids the issue raised in the previous exercise. It captures the Swift semantic of optional chaining, where the getter is optional:

    user?.location.city // Optional("Brooklyn")
    

    And the setter is non-optional:

    user?.location.city = "Los Angeles"
    // ✅
    
    user?.location.city = nil
    // 🛑 error: 'nil' cannot be assigned to type 'String'
    

    Express these requirements in a new OptionalPath<Root, Value> type.

    Solution
    struct OptionalPath<Root, Value> {
      let extract: (Root) -> Value?
      let set: (inout Root, Value) -> Void
    }
    
  9. Define appending(path:) on OptionalPath:

    extension OptionalPath {
      func appending<AppendedValue>(
        path: OptionalPath<Value, AppendedValue>
      ) -> OptionalPath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    
    Solution
    extension OptionalPath {
      func appending<AppendedValue>(
        path: OptionalPath<Value, AppendedValue>
      ) -> OptionalPath<Root, AppendedValue> {
        OptionalPath<Root, AppendedValue>(
          extract: { root in
            self.extract(root).flatMap(path.extract)
        },
          set: { root, appendedValue in
            guard var value = self.extract(root) else { return }
            path.set(&value, appendedValue)
            self.set(&root, value)
        }
        )
      }
    }
    
  10. We have seen in previous episodes and exercises that key paths and case paths have an “identity” path. Define the identity optional path:

    extension OptionalPath where Root == Value {
      static var `self`: OptionalPath {
        fatalError("unimplemented")
      }
    }
    
    Solution
    extension OptionalPath where Root == Value {
      static var `self`: OptionalPath {
        OptionalPath {
          extract: { .some($0) },
          set { $0 = $1 }
        }
      }
    }
    
  11. Implement a function that appends a key path with a case path and returns an optional path.

    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> OptionalPath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    

    Also implement a function that appends a case path with a key path and returns a new optional path.

    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> OptionalPath<Root, AppendedValue> {
        fatalError("unimplemented")
      }
    }
    
    Solution
    extension _WritableKeyPath {
      func appending<AppendedValue>(
        path: CasePath<Value, AppendedValue>
      ) -> OptionalPath<Root, AppendedValue> {
        OptionalPath<Root, AppendedValue>(
          extract: { root in path.extract(self.get(root)) },
          set: { root, appendedValue in
            self.set(&root, path.embed(appendedValue))
        }
        )
      }
    }
    
    extension CasePath {
      func appending<AppendedValue>(
        path: _WritableKeyPath<Value, AppendedValue>
      ) -> OptionalPath<Root, AppendedValue> {
        OptionalPath<Root, AppendedValue>(
          extract: { root in
            self.extract(root).map(path.get)
        },
          set: { root, appendedValue in
            guard var value = self.extract(root) else { return }
            path.set(&value, appendedValue)
            root = self.embed(value)
        }
        )
      }
    }
    
  12. Describe the hierarchy of path composition between:

    KP = (Writable) Key Paths
    CP = Case Paths
    OP = Optional Paths
    
    KP + KP = ?
    CP + CP = ?
    OP + OP = ?
    
    KP + CP = ?
    CP + KP = ?
    
    KP + OP = ?
    OP + CP = ?
    
    CP + OP = ?
    OP + CP = ?
    
    Solution
    KP + KP = KP
    CP + CP = CP
    OP + OP = OP
    
    KP + CP = OP
    CP + KP = OP
    
    KP + OP = OP
    OP + CP = OP
    
    CP + OP = OP
    OP + CP = OP
    
  13. With the solution to the previous exercise in hand, is it possible to reduce the number of append overloads you need to define between _WritableKeyPath, CasePath, and OptionalPath?

    Solution

    All compositions, whenever two paths differ, lead to OptionalPath. This means that _WritableKeyPath and CasePath are both convertible to OptionalPath. We can employ a Path protocol to describe this ability.

    protocol Path {
      associatedtype Root
      associatedtype Value
    
      var asOptionalPath: OptionalPath<Root, Value> { get }
    }
    

    And all three paths can conform:

    struct _WritableKeyPath: Path {
      var asOptionalPath: OptionalPath<Root, Value> {
        OptionalPath<Root, Value>(
          extract: { .some(self.get($0)) },
          set: self.set
        )
      }
    }
    
    struct CasePath: Path {
      var asOptionalPath: OptionalPath<Root, Value> {
        OptionalPath<Root, Value>(
          extract: self.extract,
          set: { $0 = self.embed($1) }
        )
      }
    }
    
    struct OptionalPath: Path {
      var asOptionalPath: OptionalPath<Root, Value> { self }
    }
    

    With these in hand, one can extend Path with an appending(path:) operation:

    extension Path {
      func appending<AppendedPath: Path>(
        path: AppendedPath
      ) -> OptionalPath<Root, AppendedValue> where AppendedPath.Root == Value {
        self.asOptionalPath.appending(path: path.asOptionalPath)
      }
    }
    

References

Structs 🤝 Enums

Brandon Williams & Stephen Celis • Monday Mar 25, 2019

In this episode we explore the duality of structs and enums and show that even though structs are typically endowed with features absent in enums, we can often recover these imbalances by exploring the corresponding notion.

Name a more iconic duo… We’ll wait. Structs and enums go together like peanut butter and jelly, or multiplication and addition. One’s no more important than the other: they’re completely complementary. This week we’ll explore how features on one may surprisingly manifest themselves on the other.

Getters and Key Paths

Brandon Williams & Stephen Celis • Monday Mar 19, 2018

In this episode we first define the ^ operator to lift key paths to getter functions.

Key paths aren’t just for setting. They also assist in getting values inside nested structures in a composable way. This can be powerful, allowing us to make the Swift standard library more expressive with no boilerplate.

SE-0249: Key Path Expressions as Functions

Stephen Celis & Greg Titus • Tuesday Mar 19, 2019

A proposal has been accepted in the Swift evolution process that would allow key paths to be automatically promoted to getter functions. This would allow using key paths in much the same way you would use functions, but perhaps more succinctly: users.map(\.name).

Make your own code formatter in Swift

Yasuhiro Inami • Saturday Jan 19, 2019

Inami uses the concept of case paths (though he calls them prisms!) to demonstrate how to traverse and focus on various parts of a Swift syntax tree in order to rewrite it.

Code formatter is one of the most important tool to write a beautiful Swift code. If you are working with the team, ‘code consistency’ is always a problem, and your team’s guideline and code review can probably ease a little. Since Xcode doesn’t fully fix our problems, now it’s a time to make our own automatic style-rule! In this talk, we will look into how Swift language forms a formal grammar and AST, how it can be parsed, and we will see the power of SwiftSyntax and it’s structured editing that everyone can practice.

How Do I Write If Case Let in Swift?

Zoë Smith

This site is a cheat sheet for if case let syntax in Swift, which can be seriously complicated.

Introduction to Optics: Lenses and Prisms

Giulio Canti • Thursday Dec 8, 2016

Swift’s key paths appear more generally in other languages in the form of “lenses”: a composable pair of getter/setter functions. Our case paths are correspondingly called “prisms”: a pair of functions that can attempt to extract a value, or embed it. In this article Giulio Canti introduces these concepts in JavaScript.

Optics By Example: Functional Lenses in Haskell

Chris Penner

Key paths and case paths are sometimes called lenses and prisms, but there are many more flavors of “optics” out there. Chris Penner explores many of them in this book.