EP 90 · Standalone · Feb 10, 2020 ·Members

Video #90: Composing Architecture with Case Paths

smart_display

Loading stream…

Video #90: Composing Architecture with Case Paths

Episode: Video #90 Date: Feb 10, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep90-composing-architecture-with-case-paths

Episode thumbnail

Description

Let’s explore a real world application of “case paths,” which provide key path-like functionality to enum cases. We’ll upgrade our composable architecture to use them and see why they’re a better fit than our existing approach.

Video

Cloudflare Stream video ID: 619b7012f29f784f88d66bd2d9662dfc Local file: video_90_composing-architecture-with-case-paths.mp4 *(download with --video 90)*

References

Transcript

0:05

Last week we concluded an introduction to the concept of “case paths”. It’s a tool that lets you generically isolate a single case of an enum from the rest of the cases. We were inspired to create this tool because we saw just how handy key paths are, and how they allow us to write generic algorithms that can pick apart and reassemble structs, and it stands to reason that such a thing would be really nice for enums too.

0:52

And in fact, we have a great use case for that already in the Composable Architecture. When we wanted to modularize our reducers, so that a reducer could work on just the domain it cared about while still allowing itself to be plugged into the global domain, we were naturally led to the pullback operation. It lets you take a reducer that works on a local domain and pull it back to work on the global domain. And then you can take a whole bunch of these little reducers and combine them together into one big ole reducer that powers your entire application.

1:23

In order to define this pullback operation we used key paths, and it was a great example of how you can write generic algorithms that abstract over the shape of data. We could use the key paths to pluck out the pieces of a data structure we care about, run the reducer on those smaller parts, and then use the key path again to glue everything back together.

1:41

However, our usage of key paths for the action of the reducer was a little strange. Perhaps the most glaring issue was that we had to turn to code generation to even get access to the key path. Fortunately, all of the work we did for case paths is applicable to the Composable Architecture, and it will simultaneously simplify our pullback operation and get rid of all of the code generation.

2:15

It may sound too good to be true, but it is true. But before we get to that, let’s take a quick trip down memory lane to see what our app looks like and a brief overview of how we built it. Refresher: the Composable Architecture

2:32

First, remember that we have been building a counter app with some bells and whistles. That may not sound super interesting, but it does have some advanced features that help demonstrate real world problems that an architecture needs to solve.

2:44

We can navigate to the counter screen and increment and decrement the counter.

2:50

Further, that state is persisted even if we leave and come back to this screen.

3:00

We can ask if a particular number is prime, which shows a modal.

3:08

And if the prime recognizes the number as prime we can save or remove it from a list of favorite primes.

3:11

Let’s add a few favorite primes.

3:15

We can also ask what is the “nth” prime, and this is actually firing off a network request in order to ask Wolfram Alpha, a powerful computing platform, to compute the prime. When that effect finishes it causes an alert to show.

3:27

We can also navigate to the favorite primes screen to see that all of the changes we made in the previous screen have been reflected here.

3:40

Further we can tap “Save” which executed another side effect to save the primes to disk.

3:47

We can remove a few of our favorites

3:49

And we can tap “Load” to execute another side effect for loading the primes from disk.

3:58

That’s the basics of the app. Not super complicated, but it does demonstrate some core problems that we think any architecture needs to solve:

4:06

State is shared and persisted across many screens.

4:10

Features can be built and tested in isolation. Each of these screens, both the view logic and business logic, live in their own Swift module.

4:19

The architecture describes precisely how to handle side effects, such as the network request, and the load and save effects.

4:26

The architecture is super testable. We’ve previously demonstrated that we can write very expressive tests that exercise every aspect of this application (Testable State Management: part 1 , part 2 , part 3 , part 4 ), including integration tests that test multiple layers of the application at once. The problem with enum properties

4:39

When we first began exploring the Composable Architecture, we were naturally led to the problem of how do we transform reducer functions. We had modeled the core of our architecture using reducers, for they allowed us to evolve the state of our application from its current state given a user action. Once we had built our entire application using reducers we realized that we were left with one gigantic reducer, and that wasn’t going to be great as the application changed and if we wanted to modularize the features of the application.

5:08

So, we set out to create smaller reducers that only act on the domain that they truly care about, while also defining transformations that would allow us to glue all of those disparate reducers into one big application reducer. We first found that we could transform a reducer that works on local state into one that works on global state as long as we had a key path from global state to local state. Let’s reimplement that so that we remember how it looked.

5:38

The signature looks like this: func pullback<GlobalValue, LocalValue, Action>( reducer: Reducer<LocalValue, Action>, value: WritableKeyPath<GlobalValue, LocalValue> ) -> Reducer<GlobalValue, Action> { fatalError() } Let’s describe in words what this transformation is supposed to do. When a global value and action come in, we can use the key path to extract out a local value, run our reducer on that local value, and then use the key path to plug the new local value back into the global value.

5:58

To implement this function we need to return a closure that accepts a global value and an action: func pullback<GlobalValue, LocalValue, Action>( reducer: Reducer<LocalValue, Action>, value: WritableKeyPath<GlobalValue, LocalValue> ) -> Reducer<GlobalValue, Action> { return { globalValue, action in } }

6:10

And then invoke our reducer with a local value by passing the global value a key path, and an action: func pullback<GlobalValue, LocalValue, Action>( reducer: Reducer<LocalValue, Action>, value: WritableKeyPath<GlobalValue, LocalValue> ) -> Reducer<GlobalValue, Action> { return { globalValue, action in reducer(&globalValue[keyPath: value], action) } }

6:26

And that’s basically it. That one line is simultaneously getting the local value, mutating it, and plugging it back into the global value.

6:37

Technically the reducer is returning an array of effects, but that’s exactly what we need to return from the global reducer anyway: func pullback<GlobalValue, LocalValue, Action>( reducer: Reducer<LocalValue, Action>, value: WritableKeyPath<GlobalValue, LocalValue> ) -> Reducer<GlobalValue, Action> { return { globalValue, action in let effects = reducer(&globalValue[keyPath: value], action) return effects } }

6:58

Once we knew how to transform reducers that work on local state into ones that work on global state we of course then wanted to know how to do the same, but for actions instead. If we were given a reducer that could process local actions, could we transform it into one that could process global actions?

7:14

Let’s first describe in words how such a transformation could even behave. When a global action comes in we need to try to extract a local action from it. That’s not always going to succeed, so if it doesn’t we will just return early from the reducer and do nothing. If it does succeed though, we can run our reducer with that action. We will then be left with an array of local effects, which we need to transform into an array of global effects so that it can be returned from the new reducer.

7:55

It sounds like a lot, but what we realized way back when we covered this topic is that we need some way to extract the local action from the global, and then some way to stick a local action back into the global. Back then we had just the tool for the job: enum properties.

8:10

It was a topic we had talked about before, it does what we need, and we had even open sourced a tool that allows us to automatically generate enum properties. It simply creates a computed property for every single case of every single enum in your file, allowing you to easily get access to associated values with dot syntax, and even set new associated values in an enum. This helped us write the function that transforms reducers that work on local actions to ones that work on global actions.

8:25

We can augment the current pullback function we have to take another parameter for describing how to change global actions into local actions: func pullback<GlobalValue, LocalValue, GlobalAction, LocalAction>( reducer: Reducer<LocalValue, LocalAction>, value: WritableKeyPath<GlobalValue, LocalValue>, action: WritableKeyPath<GlobalAction, LocalAction?> ) -> Reducer<GlobalValue, GlobalAction> { return { globalValue, globalAction in let effects = reducer(&globalValue[keyPath: value], action) return effects } }

8:35

Note that the action key path returns an optional LocalAction , and this is because it is not always possible to extract a local action from a global one.

8:55

Now we need to fix this function so that it compiles. First thing we can do is try to extract a local action from the global one: guard let localAction = globalAction[keyPath: action] else { return [] }

9:10

Now we have a local action, so we can run our reducer, and it will return an array of local effects: let localEffects = reducer( &globalValue[keyPath: value], localAction )

9:15

We need to transform this array of local effects into global effects, and we can use the setter capability of our action key path to do just this. We first need to map on the array to access to a particular local effect, and then we can map on the local effect to turn it into a global effects: return localEffects.map { localEffect in localEffect.map { localAction in var globalAction = globalAction globalAction[keyPath: action] = localAction return globalAction } .eraseToEffect() }

10:17

And this is exactly the the implementation of pullback that we came to when we first introduced the operation. It is nice to see how instrumental enum properties were in allowing us to define such an operation.

10:30

To make use of this operation we need to run the enum properties CLI tool that we open sourced so that enum properties would be generated for our action enums.

10:46

For example, it generated all of this code for our AppAction : enum AppAction { case counterView(CounterViewAction) case favoritePrimes(FavoritePrimesAction) var counterView: CounterViewAction? { get { guard case let .counterView(value) = self else { return nil } return value } set { guard case .counterView = self, let newValue = newValue else { return } self = .counterView(newValue) } } var favoritePrimes: FavoritePrimesAction? { get { guard case let .favoritePrimes(value) = self else { return nil } return value } set { guard case .favoritePrimes = self, let newValue = newValue else { return } self = .favoritePrimes(newValue) } } }

11:21

And with those properties defined we were able to pull reducers that worked on the counter state and the favorite primes state back to work on the full app state: public let counterViewReducer = combine( pullback( counterReducer, value: \CounterViewState.counter, action: \CounterViewAction.counter ), pullback( primeModalReducer, value: \.primeModal, action: \.primeModal ) )

12:05

And that is really powerful. This not only lets us break large, complex reducers down into smaller ones, but also allowed us to modularize our app so that the counter and favorite prime features lived in completely separate modules.

12:20

However, there is something a little strange about these enum properties. The most glaring thing is that we have to use code generation for our action enums. The CLI tool we built is simple enough to use, but it definitely adds some complexity to the process of building an app.

12:35

If you decide to run the tool manually you must remember to run it every time you add, remove or change on of your action enums. Otherwise you will have compiler errors.

12:43

If you decide to run it as part of your build process you need to make sure the tool is very efficient otherwise you risk slowing down your builds. You will also be susceptible to out of date code gen artifacts in the time between making changes to an enum and building.

13:01

So, using code gen isn’t ideal, but it may be better than writing all of that boilerplate manually.

13:11

But, there’s another problem that is a little more subtle. Let’s again look at how we transformed the local effects into global effects: localEffect.map { localAction in var globalAction = globalAction globalAction[keyPath: action] = localAction return globalAction }

13:19

The body of this map operation is really strange. Its purpose is to transform the local action into a global action, but the way we accomplish that is to make a copy of the global action, and use the key path to set the local action inside it.

13:37

This weirdness is due to how key path setters work. In order to set something with a key path you must have a root to begin with. But in the case of turning a local action into a global action, we’re not making any real use of the root value. We’re just doing a copy and set dance because that’s what key paths force us to do. Case paths in the architecture

13:58

These two little annoyances with enum properties are enough for us to consider whether or not they are the right tool for the job. And it turns out, it’s not quite right. But there is another tool that we introduced recently that is perfect for pullback : case paths!

14:21

Let’s see what happens if we replace all of our usages of key paths for actions with case paths.

14:34

We’ll start by concentrating on just the ComposableArchitecture module, which is the library code that handles our architecture. Currently the pullback operation looks like this: public func pullback< LocalValue, GlobalValue, LocalAction, GlobalAction >( _ reducer: @escaping Reducer<LocalValue, LocalAction>, value: WritableKeyPath<GlobalValue, LocalValue>, action: WritableKeyPath<GlobalAction, LocalAction?> ) -> Reducer<GlobalValue, GlobalAction> { return { globalValue, globalAction in guard let localAction = globalAction[keyPath: action] else { return [] } let localEffects = reducer( &globalValue[keyPath: value], localAction ) return localEffects.map { localEffect in localEffect.map { localAction -> GlobalAction in var globalAction = globalAction globalAction[keyPath: action] = localAction return globalAction } .eraseToEffect() } } }

14:41

And we want that to instead use a case path from GlobalAction to LocalAction .

14:45

First, we need to add CasePaths as a Swift package dependency, which we can do by choosing “Add Package Dependency…” from the “Swift Packages” submenu of the “File” menu, pasting in the package URL, and embedding it in the ComposableArchitecture module.

15:16

The core definition of a case path is as follows: // struct CasePath<Root, Value> { // let extract: (Root) -> Value? // let embed: (Value) -> Root // }

15:19

It generic over a Root and a Value , just like key paths, and it bundles up two pieces of functionality: the ability to extract a value from a root, and the ability to embed a value in a root.

15:42

Now we can swap out our action key path in the pullback function with a case path: // action: WritableKeyPath<GlobalAction, LocalAction?> action: CasePath<GlobalAction, LocalAction>

15:57

Already it’s nice that we got rid of the optional from the key path.

16:07

The first compiler error we have is when we are trying to extract a local action from a global. Previously we used the key path to subscript into the global action, but now we can just use our case path’s extract functionality: // guard let localAction = globalAction[keyPath: action] guard let localAction = action.extract(globalAction) else { return [] }

16:30

The next compiler error is in our transformation of the local effect. Now instead of juggling a mutable global action and subscripting in with a key path, we can simply use the case path’s embed functionality: localEffect.map { localAction -> GlobalAction in // var globalAction = globalAction // globalAction[keyPath: action] = localAction // return globalAction action.embed(localAction) }

17:06

We can even streamline the whole thing by passing the embed function directly to the map function: localEffect.map(action.embed) .eraseToEffect()

17:50

And now our pullback implementation has simplified quite a bit: public func pullback< LocalValue, GlobalValue, LocalAction, GlobalAction >( _ reducer: @escaping Reducer<LocalValue, LocalAction>, value: WritableKeyPath<GlobalValue, LocalValue>, action: CasePath<GlobalAction, LocalAction> ) -> Reducer<GlobalValue, GlobalAction> { return { globalValue, globalAction in guard let localAction = action.extract(globalAction) else { return [] } let localEffects = reducer( &globalValue[keyPath: value], localAction ) return localEffects.map { localEffect in localEffect.map(action.embed) .eraseToEffect() } } } Case paths in the application

17:18

The ComposableArchitecture module is now building successfully, so it’s time to start fixing the rest of the app. Luckily it’s quite easy to do.

17:24

We can start with the counter module, where we currently do this pullback to run the core counter reducer separately from the reducer that powers the prime modal: public let counterViewReducer = combine( pullback( counterReducer, value: \CounterViewState.counter, action: \CounterViewAction.counter ), pullback( primeModalReducer, value: \.primeModal, action: \.primeModal ) )

17:44

Currently this is using key paths on the CounterViewAction enum in order to pull back the action. Let’s instead change it to use the case path: public let counterViewReducer = combine( pullback( counterReducer, value: \CounterViewState.counter, action: CasePath(CounterViewAction.counter) ), pullback( primeModalReducer, value: \.primeModal, action: CasePath(CounterViewAction.primeModal) ) )

18:24

We did lose some of the type inference we had before with key paths, but maybe when case paths become a first-class language feature, we’ll get it back!

18:33

Now the Counter module is compiling, and we can comment out a bunch of code that was previously generated: public enum CounterViewAction: Equatable { case counter(CounterAction) case primeModal(PrimeModalAction) // var counter: CounterAction? { // get { // guard case let .counter(value) = self else { return nil } // return value // } // set { // guard case .counter = self, let newValue = newValue // else { return } // self = .counter(newValue) // } // } // // var primeModal: PrimeModalAction? { // get { // guard case let .primeModal(value) = self else { return nil } // return value // } // set { // guard // case .primeModal = self, // let newValue = newValue // else { return } // self = .primeModal(newValue) // } // } }

18:52

The next file that is not compiling is the ContentView inside the main application target, where we are doing the following pullback : let appReducer = combine( pullback( counterViewReducer, value: \AppState.counterView, action: \AppAction.counterView ), pullback( favoritePrimesReducer, value: \.favoritePrimes, action: \.favoritePrimes ) )

19:05

Let’s change those key paths to case paths: let appReducer = combine( pullback( counterViewReducer, value: \AppState.counterView, action: CasePath(AppAction.counterView) ), pullback( favoritePrimesReducer, value: \.favoritePrimes, action: CasePath(AppAction.favoritePrimes) ) )

19:24

And miraculously everything is now building. And if we run the app we will see that everything works exactly as it did before, but now we get to comment out even more code that was previously generated: enum AppAction { case counterView(CounterViewAction) case favoritePrimes(FavoritePrimesAction) // var counterView: CounterViewAction? { // get { // guard case let .counterView(value) = self else { return nil } // return value // } // set { // guard case .counterView = self, let newValue = newValue // else { return } // self = .counterView(newValue) // } // } // // var favoritePrimes: FavoritePrimesAction? { // get { // guard case let .favoritePrimes(value) = self // else { return nil } // return value // } // set { // guard // case .favoritePrimes = self, // let newValue = newValue // else { return } // self = .favoritePrimes(newValue) // } // } }

19:49

We can maybe even make things just a little bit nicer. You may remember that in our last episode on case paths we introduced a prefix operator that made the construction of case paths look somewhat similar to key paths. You could just put a forward slash in front of any enum’s case and you would instantly get the case path for that case. Let’s make that change to this project.

20:09

In the Counter module we can do: public let counterViewReducer = combine( pullback( counterReducer, value: \CounterViewState.counter, action: /CounterViewAction.counter ), pullback( primeModalReducer, value: \.primeModal, action: /CounterViewAction.primeModal ) )

20:24

However, if custom operators are not you’re thing, no worries! It is perfectly fine to construct case paths the regular way, by invoking the initializer. It isn’t really that much more verbose, and that may be more palatable to your team.

20:36

And in the main application target we can do: let appReducer = combine( pullback( counterViewReducer, value: \AppState.counterView, action: /AppAction.counterView ), pullback( favoritePrimesReducer, value: \.favoritePrimes, action: /AppAction.favoritePrimes ) ) What’s the point?

20:48

So, usually right around this time is when we’d like to ask “what’s the point?”. It’s the time where we get to bring things down to earth and make sure that we are talking about real world, practical things.

21:00

But this time the whole episode has been very practical. We’ve already spent a ton of time explaining why we think the Composable Architecture is a practical way of building applications. But it had the slight downside that if you wanted to fully modularize your application you needed to either write some boilerplate or do a bit of code generation. We’ve now completely removed that boilerplate and code generation, which means the Composable Architecture is even easier to adopt.

22:05

Further, we will soon have even more applications of case paths in other areas of programming. It really is a powerful tool.

22:19

Until next time… References CasePaths Brandon Williams & Stephen Celis CasePaths is one of our open source projects for bringing the power and ergonomics of key paths to enums. https://github.com/pointfreeco/swift-case-paths Structs 🤝 Enums Brandon Williams & Stephen Celis • 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. https://www.pointfree.co/episodes/ep51-structs-enums Make your own code formatter in Swift Yasuhiro Inami • 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. Note 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. https://www.youtube.com/watch?v=_F9KcXSLc_s Introduction to Optics: Lenses and Prisms Giulio Canti • 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. https://medium.com/@gcanti/introduction-to-optics-lenses-and-prisms-3230e73bfcfe 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. https://leanpub.com/optics-by-example Downloads Sample code 0090-composing-architecture-with-case-paths Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .