EP 133 · Concise Forms · Feb 1, 2021 ·Members

Video #133: Concise Forms: Bye Bye Boilerplate

smart_display

Loading stream…

Video #133: Concise Forms: Bye Bye Boilerplate

Episode: Video #133 Date: Feb 1, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep133-concise-forms-bye-bye-boilerplate

Episode thumbnail

Description

The Composable Architecture makes it easy to layer complexity onto a form, but it just can’t match the brevity of vanilla SwiftUI…or can it!? We will overcome a Swift language limitation using key paths and type erasure to finally say “bye!” to boilerplate.

Video

Cloudflare Stream video ID: 8e690f1b1986652a8e854aec2c4b074b Local file: video_133_concise-forms-bye-bye-boilerplate.mp4 *(download with --video 133)*

References

Transcript

0:05

This is a pretty comprehensive test that would have been impossible to write with the way the view model version of the code is written. By doing just a bit of upfront work we can get a ton of code coverage.

0:20

With just a little more work we can also write a test for the unhappy path, where the user denies us permission to their notifications, but we will save that as an exercise for the viewer.

0:34

OK, so we have done a really comprehensive overview of how forms work in vanilla SwiftUI applications and in Composable Architecture applications. There really doesn’t seem to be a clear winner as far as conciseness goes. On the one hand SwiftUI handles very simple forms amazingly, reducing boilerplate and noise, but things get messy fast when you are handling more complex, real world scenarios. On the other hand the Composable Architecture comes with a decent amount of boilerplate for a very simple form, but then really shines as you start layering on the complexities, giving you a wonderful story for dependencies, side effects and testing.

1:12

So, this is a bit of a bummer. We love the Composable Architecture, but it’s things like this boilerplate problem which can turn away people from using it even when there are so many other benefits to be had.

1:25

Well, luckily for us the boilerplate problem can be solved. Using some of the more advanced features of Swift we can eliminate almost all of the boilerplate when dealing with simple form data, which will hopefully make the Composable Architecture solution more palatable for those worried about boilerplate.

1:44

So let’s take all of the work we’ve done with the Composable Architecture and copy it over and chip away at the problem of eliminating that boilerplate. The problem: action overload

1:56

We’re going to start by copying the contents of TCAForm.swift into a new ConciseTCAForm.swift . That means we’ll have a bunch of duplicate symbols, but we can do a few things to fix them. First we can delete some duplicates from the file, like SettingsState , SettingsEnvironment , UserNotificationsClient and more. We can rename the rest of the duplicates by prefixing Concise to their names, and a few other small fixes should get us into building order again. enum ConciseSettingsAction { … let conciseSettingsReducer = Reducer< SettingsState, ConciseSettingsAction, SettingsEnvironment > { state, action, environment in … .map(ConciseSettingsAction.authorizationResponse) … .map(ConciseSettingsAction.notificationSettingsResponse) … } struct ConciseTCAFormView: View { let store: Store<SettingsState, ConciseSettingsAction> … var body: some View { … send: ConciseSettingsAction.displayNameChanged … send: ConciseSettingsAction.protectMyPostsChanged … send: ConciseSettingsAction.sendNotificationsChanged … send: ConciseSettingsAction.digestChanged … send: ConciseSettingsAction.dismissAlert … } } struct ConciseTCAFormView_Previews: PreviewProvider { static var previews: some View { ConciseTCAFormView( store: Store( initialState: SettingsState(), reducer: conciseSettingsReducer, environment: SettingsEnvironment( mainQueue: DispatchQueue.main.eraseToAnyScheduler(), userNotifications: .init( getNotificationSettings: { .init(value: .init(authorizationStatus: .denied)) }, registerForRemoteNotifications: { fatalError() }, requestAuthorization: { _ in fatalError() } ) ) ) ) } }

2:59

The main source of boilerplate is the fact that we need a whole separate action for each field of state that is updated via a UI control. So the more controls we have, the more enum cases we will have, which will mean the more cases that have to be handled in the reducer.

3:18

What if we could provide a small escape hatch so that when you send a specific action you could provide a little closure that describes how to mutate the state? Then you could send this single action from all of the UI controls rather than needing a separate action for each control.

3:34

We could do this by adding a .form case to the enum with a closure that takes an inout SettingsState : enum ConciseSettingsAction: Equatable { … case form((inout SettingsState) -> Void) }

3:48

Unfortunately that breaks the automatic Equatable conformance because there is no reasonable way to determine equality of two (inout SettingsState) -> Void functions.

3:59

Having this Equatable conformance is really important because it’s what allows us to write comphrehsive tests by asserting what kinds of actions are sent into the store from effects. So we definitely do not want to lose that capability, but it’s also not clear how to define equality. For now let’s just put a fatalError in to get this enum compiling and we’ll worry about this in a moment: enum ConciseSettingsAction: Equatable { … static func == ( lhs: ConciseSettingsAction, rhs: ConciseSettingsAction ) -> Bool { fatalError() } }

4:24

There’s a compiler error in the reducer because we need to handle the new .form action. This can be done by grabbing the closure that is attached to the action and running it on the current state: case let .form(update): update(&state) return .none

4:46

This escape hatch now allows us to make little state updates directly from the view. For example, when creating the binding for the display name text field we could send a .form action to directly change the display name: TextField( "Display name", text: Binding.init( get: { viewStore.displayName }, set: { displayName in viewStore.send(.form { $0.displayName = displayName }) } ) // viewStore.binding( // get: \.displayName, // send: ConciseSettingsAction.displayNameChanged // ) )

5:31

This is pretty intense, but it works, and I bet if we tried hard enough we could provide a binding helper method to hide some of the messiness. Correction It is also totally possible to reuse the existing viewStore.binding helper, which cleans things up a little, but remains verbose in the send argument: text: viewStore.binding( get: \.displayName, send: { displayName in .form { $0.displayName = displayName } } )

5:39

However, is this the road we really want to go down?

5:43

Definitely not.

5:45

For one thing, we would not be able to recover the full functionality of the feature as it is now. We need the ability to listen for specific events, like when the notifications toggle is tapped so that we can perform extra logic, like requesting permissions or showing an alert. That is essentially impossible to do since all we have at our disposal in the reducer is this closure.

6:17

But more importantly, we will not pursue this path because it goes against some of the key tenets of the Composable Architecture. All state mutation in your application should happen in the reducer so that there is only one place to look when trying to figure out how data flows through your application. If you are trying to debug a problem with your application it can be greatly simplifying to know there’s only one place where your application logic resides, and having these little .form actions scattered about ruins that consistency.

6:52

Further, this change also complicates the testability story of the Composable Architecture, which is one of its greatest features. It causes a bit of friction for testing because we now have to implement the Equatable conformance by hand, and I’m not even sure how to implement it for the .form case. The assertions we make are also going to be weaker because the mutation logic has been moved out to the view, and therefore can’t be tested.

7:23

Although this is not the approach we are going to take, it is kinda going in the right direction. We’d love if we could have a single action that all changes to the form state could be funneled through because that would almost entirely eliminate the boilerplate.

7:38

Right now the .form action holds onto a totally free-form, can-do-anything closure that can mutate a SettingsState . But we don’t actually need all that power. There is a much smaller subset of mutations that we actually care about, and that’s the type of mutations that simply wholesale replace the value in a field of our SettingsState and do nothing else. When a UI control is updated we just need to update the corresponding field in our state, and possibly perform a little bit of extra logic, such as showing alerts or firing off side effects.

8:09

Turns out that Swift has first class support for this special type of mutation, and it’s called key paths. Key paths are one of the most amazing features of Swift, and as far we know there isn’t a single programming language out there with first class support for this concept. Perhaps we can replace the free-form .form closure with just a key path and everything will work out nicer.

8:35

So instead of .form holding onto a closure it needs to hold onto a key path, along with the value that we want to update the key path with. However we already run into problems because we don’t know the type of the value we are trying to access: // case form((inout SettingsState) -> Void) case form(WritableKeyPath<SettingsState, ???>, ???)

9:14

We could maybe just use Any : case form(WritableKeyPath<SettingsState, Any>, Any)

9:24

But this isn’t possible in Swift, and even if it were, it would be a pretty dangerous thing to do because we would no longer have any guarantees that the Any in the key path is going to be the same type as the Any for the value .

9:35

One way to fix this would be if Swift had support for using generics in enum cases. As we’ve seen many times on Point-Free, enum cases are basically functions. They take arguments and they return values in the enum. And Swift functions can be generic, but unfortunately we can’t introduce generics to an enum case. If we could then we could do something like this: case form<Value>(WritableKeyPath<SettingsState, Value>, value: Value)

10:04

That makes this a lot more type safe since now the type we are projecting into with the key path is the same type as the value argument.

10:11

Unfortunately we can’t use this syntax right now (hopefully someday in the future), but we can still play with it for a bit to see if its promising and if we should implementing it in another way.

10:23

If we had this feature we could destructure the .form case in the reducer to grab the keyPath and value from the case: // case let .form(update): // update(&state) case let .form(keyPath, value: value):

10:37

What’s interesting here is that because there was a generic on the .form case we actually have no idea what type keyPath and value are. They could be anything. In fact, we might as well think of them as WritableKeyPath<SettingsState, Any> and Any , except with the added constraint that they types are equal.

10:56

However, interestingly, we don’t need to know what the types are because we can still do interesting stuff with the information we have. For one thing, we can use the key path to dive down into our state value and mutate it: case let .form(keyPath, value: value): state[keyPath: keyPath] = value

11:12

So even without knowing the types this would be perfectly valid code and statically checked to be type safe.

11:17

Even more interesting, key paths are Equatable . They’re even Hashable ! This means we could even check which key path was passed to us so that we could layer on additional logic: case let .form(keyPath, value: value): state[keyPath: keyPath] = value if keyPath == \SettingsState.displayName { // TODO: truncate name } else if keyPath == \SettingsState.sendNotifications { // TODO: request notifications authorization }

11:57

That’s pretty amazing. The fact that key paths are Equatable may sound weird at first, but here we are getting a really great use for it.

12:04

So this is looking pretty promising so far, but what does it look like to use this in the view? We can construct a binding from scratch that sends this .form action under the hood: TextField( "Display name", text: Binding( get: { viewStore.displayName }, set: { viewStore.send(.form(\.displayName, $0)) } ) // viewStore.binding( // get: \.displayName, // send: ConciseSettingsAction.displayNameChanged // ) )

12:34

That’s not bad, and we could definitely cook up a ViewStore binding helper to hide away those details. The solution: a type-erased form action

12:40

So this looks pretty great, but unfortunately it all hinges on a non-existent feature of Swift. Luckily we can work around Swift’s deficiencies here.

12:49

The overarching feature that is missing from Swift for this to work is know as “existential types.” We’ve had some light discussions on Point-Free about existential types in the past, for example when we discussed Combine schedulers. The way to work around the lack of full support for existential types in Swift is to introduce what are known as “type erased” wrappers.

13:15

For example, we created an AnyScheduler type eraser so that we could deal with existential schedulers. The reason we wanted to do that is that we found if we wrote Combine code in a way that would allow us to swap out live schedulers, like DispatchQueue s, for test-friendly schedulers, like ImmediateScheduler , then we needed to introduce generics all over the place so that we could choose what kind of scheduler was being used. This turned out to be hugely problematic because generics started to infect our code all over the place, and things as simple as a SwiftUI view all the sudden needed to be generic over the type of scheduler its view model used.

13:57

So, creating a type erased scheduler type allowed our code to be generic over many types of schedulers without needing to literally introduce a generic to all of our types. And if you squint that’s kinda what is happening here. While it’s true that we did introduce a generic, it was only added to a case of the enum. It’s not actually visible to the outside world at all. So that allows ConciseSettingsAction to behave as if its generic since the .form case can deal with lots of different types of data, all the while not actually needing to make ConciseSettingsAction generic.

14:34

This is an incredibly important concept for building type safe, expressive APIs, and we’ll have a lot more to say about it in the future on Point-Free, but for now let’s start fixing this situation.

14:46

Let’s go back to our action that is using the theoretical Swift syntax: case form<Value>(WritableKeyPath<SettingsState, Value>, Value)

14:55

Since Swift is incapable of using generics in this spot we have to resort to manually perform type erasure to get rid of the generic. Interestingly, Swift comes with a partially type erased key path known as a PartialKeyPath . It preserves the root type but erases the value type that is being projected into. This allows us to get rid of the Value generic for the key path: case form<Value>(PartialKeyPath<SettingsState>, Value)

15:21

Now we just need to erase the remaining Value instance, which is the value we want to update inside of SettingsState .

15:33

We did this before and decided against it, but let’s try putting in an Any for the Value generic: case form(PartialKeyPath<SettingsState>, value: Any)

15:41

We’ve now successfully erased the Value generic completely, but it’s problematic that there’s no guarantee that the type of the value in the Any is the same type that the partial key path is projecting into. We could do things like this, which are completely non-sensical: ConciseSettingsAction.form(\.displayName, value: 1)

16:45

We can prevent this from happening by restricting the ways in which this case can be created. Enum cases work as public initializers, so in order to take control of things we can create a dedicated type for holding the key path and value that can be restricted in how it is initialized. struct FormAction { let keyPath: PartialKeyPath<SettingsState> let value: Any }

17:34

This holds all the same data as the enum case, so we could now swap it in and the code is basically the same as before. case form(FormAction)

17:51

However now we have the freedom to restrict the way in which FormAction s are created and completely prevent those invalid states. init<Value>( _ keyPath: WritableKeyPath<SettingsState, Value>, _ value: Value ) { self.keyPath = keyPath self.value = value }

19:15

Let’s also go ahead and make this type generic so that it will work with any type of action, not just SettingsState : struct FormAction<Root> { let keyPath: PartialKeyPath<Root> let value: Any init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) { self.keyPath = keyPath self.value = value } }

19:38

With this type defined we can use it in the .form case, which will guarantee that when this case is constructed the type of the value of the key path and the type of the value passed along will be the same: case form(FormAction<SettingsState>)

19:54

Already there is something really cool we can do. Remember that key paths are Equatable , which means that we now have a fighting chance to make this FormAction type equatable. Let’s start by commenting out the stub Equatable conformance we made before: // static func == ( // lhs: ConciseSettingsAction, rhs: ConciseSettingsAction // ) -> Bool { // fatalError() // }

20:17

And let’s try to make FormAction conform to Equatable to see what needs to be done: struct FormAction<Root>: Equatable { … }

20:26

Right now this does not conform to Equatable because the value field is of type Any , and Any is not Equatable .

20:42

The type Any is fully type erased type. There is absolutely no information retained about what the type was before it was erased. That’s a bit much. Turns out we can erase the type down to the bare essentials that we actually care about, in particular its Equatable conformance. Swift comes with a few of these kinds of type erased wrappers, such as AnyHashable , AnyCollection and AnyIterator , so we would hope we could do something like: struct FormAction<Root>: Equatable { let keyPath: PartialKeyPath<Root> let value: AnyEquatable init<Value: Equatable>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) { self.keyPath = keyPath self.value = value } }

21:24

However, Swift does not ship with an AnyEquatable for some reason. It’s too bad because it would be pretty handy, like we see here.

21:33

We could create the AnyEquatable type eraser from scratch, but we’ll actually save that as an exercise for the viewer. Instead we will just use AnyHashable since the Hashable protocol inherits from the Equatable protocol. This isn’t ideal, but it gets us moving more quickly so that we can see how fruitful this approach will be. struct FormAction<Root>: Equatable { let keyPath: PartialKeyPath<Root> let value: AnyHashable init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) where Value: Hashable { self.keyPath = keyPath self.value = AnyHashable(value) } }

22:52

Now the ConciseSettingsAction is compiling for the first time in a while.

23:07

Now let’s see what it looks like to implement this action in the reducer. We can start by binding the form action from the .form case: case let .form(formAction):

23:21

The formAction holds onto a key path that we can use to dive into the state value, and it contains a value that we hope we could use to overwrite the field at this key path: state[keyPath: formAction.keyPath] = formAction.value

23:27

But unfortunately this does not work. The key path is only a PartialKeyPath , and so it does not have any writing capabilities. Only WritableKeyPath s can do that, but we erased all of that information after initialization.

23:49

What we need to do is hold onto a little more information in the FormAction so that it’s not completely erased. In particularly, we want to retain the setter functionality of the key path after it has been erased to a PartialKeyPath . We can do this by adding a closure field to FormAction that mutates a Root by performing a set with the key path and value held by the FormAction : struct FormAction<Root>: Equatable { … let setter: (inout Root) -> Void init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) where Value: Hashable { self.keyPath = keyPath self.value = AnyHashable(value) self.setter = { $0[keyPath: keyPath] = value } } }

25:03

This unfortunately breaks equatability because there is no reasonable way to determine if two closures of the form (inout Root) -> Void are equal.

25:10

However, we don’t actually care about testing if two closures are equal. Those closures are completely determined by the key path handed to us, and that key path is Equatable . So we can implement a custom Equatable conformance that simply ignores that closure field: static func == (lhs: Self, rhs: Self) -> Bool { lhs.keyPath == rhs.keyPath && lhs.value == rhs.value }

25:45

Now we have access to this setter in our reducer, and so implementing the .form functionality is as simple as calling it: case let .form(formAction): formAction.setter(&state)

26:03

And now we can even get those conditionals compiling that check the form action key path, since key paths are equatable: if formAction.keyPath == \SettingsState.displayName { // TODO: truncate name } else if formAction.keyPath == \SettingsState.sendNotifications { // TODO: request notifications authorization }

26:22

Next let’s see what it takes to send this action from the view.

26:29

Currently we have this left over from when we were theorizing how to solve our problems: TextField( "Display name", text: Binding( get: { viewStore.displayName }, set: { viewStore.send(.form(\.displayName, $0)) } ) )

26:35

And turns out that is pretty close to what we need to do. We just need to wrap the case arguments in an initializer to bundle it into a FormAction : TextField( "Display name", text: Binding( get: { viewStore.displayName }, set: { viewStore.send(.form(.init(\.displayName, $0))) } )

26:51

That compiles, and in fact now the entire application compiles, but I think we can shorten this a bit more. We can write a binding helper on ViewStore that works like the other helpers do, except it will be specifically tuned for dealing with FormAction s and key paths: extension ViewStore { func binding<Value>( keyPath: WritableKeyPath<State, Value>, send action: @escaping (FormAction<State>) -> Action ) -> Binding<Value> where Value: Hashable { self.binding( get: { $0[keyPath: keyPath] }, send: { action(.init(keyPath, $0)) } ) } }

29:23

Now we can shorten our text field binding to just: TextField( "Display name", text: viewStore.binding( keyPath: \.displayName, send: ConciseSettingsAction.form ) )

29:45

There is a subtle naming difference from our other binding helper, which simply uses a get argument and getter function. Here we are using keyPath , since the argument passed along is a little more restrictive: it has to be a writable key path, it can’t simply be any getter function.

30:44

Let’s quickly update the other bindings we have: Toggle( "Protect my posts", isOn: viewStore.binding( keyPath: \.protectPosts, send: ConciseSettingsAction.form ) ) … Toggle( "Send notifications", isOn: viewStore.binding( keyPath: \.sendNotifications, send: ConciseSettingsAction.form ) ) … Picker( "Activity digest", selection: viewStore.binding( keyPath: \.digest, send: ConciseSettingsAction.form ) )

31:34

These all compile just fine, and finally we have an alert binding to update: .alert( item: viewStore.binding( keyPath: \.alert, send: ConciseSettingsAction.form ) ) { alert in Referencing instance method ‘binding(keyPath:send:)’ on ‘Optional’ requires that ‘AlertState’ conform to ‘Hashable’

31:45

Unfortunately this doesn’t compile as-is because the AlertState value doesn’t conform to Hashable , and FormAction requires that it does. We can add this conformance simply enough. struct AlertState: Equatable, Hashable, Identifiable {

32:09

And now things build just fine! But this is a great example as to why we’d prefer that FormAction s were a little less restrictive using something like an AnyEquatable type eraser rather than AnyHashable . In this case we’ve forced ourselves to make a type hashable when we don’t really care about hashability. In this case it wasn’t so bad, since it was automatically synthesized for us, but this may not always be the case.

32:42

Now that we’ve changed all of our bindings to run off of the new .form action we can comment out all the actions that dealt with a specific UI component: enum ConciseSettingsAction: Equatable { case authorizationResponse(Result<Bool, NSError>) // case digestChanged(Digest) // case dismissAlert // case displayNameChanged(String) case notificationSettingsResponse(UserNotificationsClient.Settings) // case protectMyPostsChanged(Bool) case resetButtonTapped // case sendNotificationsChanged(Bool) case form(FormAction<SettingsState>) } Eliminating reducer boilerplate

33:13

This is looking pretty promising. We have deleted 5 cases of our action enum that were only there for UI controls to funnel their output into the system and consolidated things down to a single case. That’s a 5-to-1 ratio for code deletion, and we hope to continue to see these benefits.

33:37

But we’ve broken the reducer because we removed some cases from the enum, so let’s fix that.

33:47

A few of those cases only do what the new .form action does, so we can just comment them out: // case let .digestChanged(digest): // state.digest = digest // return .none // case let .dismissAlert: // state.alert = nil // return .none … // case let .protectMyPostsChanged(isOn): // state.protectMyPosts = isOn // return .none

34:11

The other actions are a little more complicated. They layer on additional functionality beyond just updating state.

34:24

We can recapture this functionality by checking which key path is being dealt with right in the reducer. For example, if the key path is the displayName key path, then we can layer on the truncation logic: if formAction.keyPath == \SettingsState.displayName { state.displayName = String(state.displayName.prefix(16)) }

35:12

This is only possible because key paths are Equatable , which may seem like a weird feature at first but it’s incredibly handy.

35:22

Next, if the key path being updated is the sendNotifications key path then we need to layer on the notification permissions logic. This is a little more complicated because in this case we don’t actually want to eagerly update sendNotifications , but rather only update it after getting a response back from .getNotificationSettings . So, after getting past the guard we will actually flip sendNotifications back to false so that we can handle that later: else if formAction.keyPath == \SettingsState.sendNotifications { guard state.sendNotifications else { return .none } state.sendNotifications = false return environment.userNotifications.getNotificationSettings() .receive(on: environment.mainQueue) .map(ConciseSettingsAction.notificationSettingsResponse) .eraseToEffect() }

37:03

Amazingly, if we run the app with this more concise form view: @main struct ConciseFormsApp: App { var body: some Scene { WindowGroup { NavigationView { // VanillaSwiftUIFormView() // TCAFormView( // store: Store( // initialState: SettingsState(), // reducer: settingsReducer, // environment: .live // ) ConciseTCAFormView( store: Store( initialState: SettingsState(), reducer: conciseSettingsReducer, environment: SettingsEnvironment( mainQueue: DispatchQueue.main.eraseToAnyScheduler(), userNotifications: .live ) ) ) } } } }

37:14

It behaves exactly as it did before. We can ask for permissions, and the toggle optimistically turns on, and then if we deny permissions it will turn off.

37:38

We’ve recovered all of the functionality from before, but with a lot less boilerplate. We now have only a single action in place of what used to be 5 actions, and even better, adding new form actions will not incur any additional boilerplate and will only need a minimal set of changes.

37:52

For example, suppose that when notifications are turned on we allow the user to determine whether or not they want their notifications going to the mobile device or to their email. To do this we can first add two new booleans to our state: struct SettingsState: Equatable { … var sendMobileNotifications = false var sendEmailNotifications = false }

38:11

And then in the view we can add two toggles inside the check for if sendNotifications is true : if viewStore.sendNotifications { Toggle( "Email", isOn: viewStore.binding( keyPath: \.sendEmailNotifications, send: ConciseSettingsAction.form ) ) Toggle( "Mobile", isOn: viewStore.binding( keyPath: \.sendMobileNotifications, send: ConciseSettingsAction.form ) ) … }

38:40

And that’s it! That’s all it takes to get the view automatically updating our state when those toggles are changed. No need to add new actions to the action enum, and no need to update the reducer to do some basic setter logic. We could add as many properties as we want to this screen and there would be no increase in boilerplate at all.

39:03

Sure enough, if we build and run the app, our new toggles appear and are fully functional, which means that the reducer is doing its job and updating the associated state. Impact on tests

39:30

Let’s see how all of these changes to our settings feature has impacted testing the feature. We’ll hop over to ConciseFormsTests.swift and update our tests to use the `conciseFormReducer: reducer: conciseSettingsReducer,

39:57

And this instantly gives us a bunch of compiler errors because we are referencing a bunch of actions that no longer exist in the “concise settings” domain.

40:06

Instead of sending those actions we can just send a FormAction populated with the key path and value we want to update to. For example, sending the .displayNameChanged action now looks like this: // .send(.displayNameChanged("Blob")) { .send(.form(.init(\.displayName, "Blob"))) { $0.displayName = "Blob" }

40:24

We can similarly update all of the actions to use the new FormAction : store.assert( .send(.form(.init(\.displayName, "Blob"))) { $0.displayName = "Blob" }, .send(.form(.init(\.displayName, "Blob McBlob, Esq."))) { $0.displayName = "Blob McBlob, Esq" }, .send(.form(.init(\.protectPosts, true))) { $0.protectPosts = true }, .send(.form(.init(\.digest, .weekly))) { $0.digest = .weekly } )

40:46

This gets this test compiling, and if we run tests they pass.

40:59

Updating the next test to use the conciseSettingsReducer we will see a similar compiler error, and it can be fixed in the same way: //.send(.sendNotificationsChanged(true)), .send(.form(.init(\.sendNotifications, true))),

41:25

And now this test is compiling and passes when we run.

41:42

Amazingly there has been no impact on testing at all. Even though we are using some advanced features of Swift, such as key paths and type erasure, we have still been able to maintain an Equatable conformance on our actions enum, which is what allows us to write expressive and exhaustive tests like the above. Improving ergonomics

42:05

Now that we have recaptured all of the functionality of our first attempt at building this screen in the Composable Architecture, let’s spend some time improving the ergonomics. There are a few small things we can do to clean up our code even more.

42:26

Let’s start with how we are handling the form action in the reducer: case let .form(formAction): formAction.setter(&state) if formAction.keyPath == … { } return .none

42:47

This bit of code is always going to look the same, no matter what form we are dealing with. We are always going to apply the action’s setter to the state, and then we will optionally check the action’s key path to see if there is additional logic we want to layer on, and then finally we will return a .none effect.

43:11

What if we could hide some of the details from the user in a higher-order reducer. So, instead of manually calling the action’s setter on your state, you could just enhance your reducer to be one that works with form actions: let conciseSettingsReducer = Reducer< SettingsState, ConciseSettingsAction, SettingsEnvironment > { state, action, environment in … } .form()

43:31

Then you could get rid of the setter logic and just have your reducer focus on the additional logic you want layered on top of the base form logic: case let .form(formAction): // formAction.setter(&state)

43:38

Let’s start trying to implement this .form higher-order reducer to see what it needs to be provided to do its job.

43:46

We can extend the Reducer type and get a basic method in place: extension Reducer { func form() -> Self { Self { state, action, environment in } } }

44:07

We want to inspect the action that comes in this reducer so that if it’s a FormAction we will run a little bit of extra logic, in particular the setter logic. There is a tool that is perfect for isolating specific cases of an enum, and it’s something we have discovered in Point-Free episodes a year ago, and that’s case paths . Case paths are to enums what key paths are to structs. They allow you to isolate and transform a single case of an enum, just as key paths allow you to isolate and transform a single field of a struct.

44:46

So, if we provide this higher-order reducer a case path to isolate a FormAction from the rest of the actions, then we would have the ability to layer on that additional logic. Let’s update the signature to reflect this: func form( action formAction: CasePath<Action, FormAction<State>> ) -> Self { … }

45:45

And then in the reducer we can try to extract a FormAction from the action, and if that fails we will just run the original reducer without layering on any additional logic: guard let formAction = formAction.extract(from: action) else { return self.run(&state, action, environment) }

46:16

However, if we are able to extract the FormAction , then we can run it’s setter on the state, which will take care of updating a particular field in the state so that we don’t have to do that work in the main settings reducer ourselves: formAction.setter(&state)

46:47

And finally we need to run the original reducer and return its effects since it may decide to layer on even more logic later: return self.run(&state, action, environment)

47:03

Now we can update our invocation of the .form higher-order reducer so that it passes along the case path that identifies which case of our action enum holds all of the form actions: .form(action: /ConciseSettingsAction.form)

47:42

This provides you a single, simple-to-use method for instantly enhancing a reducer with the capabilities of concise form logic. Once you put the pieces into place you will be free to add as many UI controls to your view as you want, and you will never incur any additional boilerplate.

48:11

But it gets even better. I think the if / else if logic in the .form action is a little messy: if formAction.keyPath == \SettingsState.displayName { … } else if formAction.keyPath == \SettingsState.sendNotifications { … }

48:18

First of all it incurs another level of indentation because we are inside the .form case. And second of all it has a lot of repetition, both in that we have to repeat formAction.keyPath for each if statement, but also we can’t use type inference when specifying the key path: if formAction.keyPath == \.displayName { Cannot infer key path type from context; consider explicitly specifying a root type

48:48

We can partially fix one of these problems by doing a switch on the formAction's key path instead of an if / else if : switch formAction.keyPath { case \SettingsState.displayName: // truncation logic case \SettingsState.displayName: // notification logic default: return .none } But we’re no less indented since switch cases have their statements indented, it’s still not possible to drop the \SettingsState. repetition from the key path, and we have the additional default to handle, since key paths aren’t exhaustive.

49:29

What if we could flatten all of this logic into the main action switch we have above, and destructure the .form action based on the key path we are interested in: case .form(\.displayName): state.displayName = String(state.displayName.prefix(16)) return .none case .form(\.sendNotifications): guard state.sendNotifications else { return .none } state.sendNotifications = false return environment.userNotifications.getNotificationSettings() .receive(on: environment.mainQueue) .map(ConciseSettingsAction.notificationSettingsResponse) .eraseToEffect() case .form: return .none

50:17

That would make this form code look almost identical to the way we handle all the other actions in this reducer.

50:34

Amazingly, it is possible to do this using a lesser known feature of Swift. In Swift you can tap into the machinery of pattern matching in a switch statement by implementing an overload of the ~= (twiddle equals) operator. func ~= (pattern, value) -> Bool { }

50:57

This infix operator takes two arguments: on the left, the pattern you want to match, i.e. , the value you pattern matched in the case , and of the right, the value being switch ed on.

51:32

For example, this function is the mechanism called when you to pattern match against an integer using a range: switch 42 { case 10...: print("10 or more") default: break } 10... ~= 42

52:28

In our case we have a key path handed to the case ’s destructure and a FormAction that we are switching over: func ~= <Root, Value>( keyPath: WritableKeyPath<Root, Value>, formAction: FormAction<Root> ) -> Bool { } When this function returns true it will mean that Swift will execute the body of the case statement that triggered it. We want this to happen when the key path provided matches the key path in the formAction : func ~= <Root, Value>( keyPath: WritableKeyPath<Root, Value>, formAction: FormAction<Root> ) -> Bool { formAction.keyPath == keyPath }

53:23

Amazingly everything compiles now. We are able to succinctly destructure the specific key paths we care about from the .form action using this simple syntax: case .form(\.displayName):

54:22

There’s one more ergonomic improvement we can make, and that’s in our tests. Currently when we need to send form actions in our TestStore it looks a little awkward: store.assert( .send(.form(.init(\.displayName, "Blob"))) { $0.displayName = "Blob" }, .send(.form(.init(\.displayName, "Blob McBlob, Esq."))) { $0.displayName = "Blob McBlob, Esq" }, .send(.form(.init(\.protectPosts, true))) { $0.protectPosts = true }, .send(.form(.init(\.digest, .weekly))) { $0.digest = .weekly } )

54:44

Right now we do .form(.init(…)) because we have to construct a FormAction to be passed to the .form enum case. But what the FormAction really represents is the idea of us wanting to set a value at a key path. I think we could give a proper name to this initializer rather than using .init to make this very clear: static func set<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) where Value: Hashable -> Self { self.init(keyPath, value) }

55:54

Now we can use the syntax .form(.set(…)) to represent sending form actions in tests: store.assert( .send(.form(.set(\.displayName, "Blob"))) { $0.displayName = "Blob" }, .send(.form(.set(\.displayName, "Blob McBlob, Esq."))) { $0.displayName = "Blob McBlob, Esq" }, .send(.form(.set(\.protectPosts, true))) { $0.protectPosts = true }, .send(.form(.set(\.digest, .weekly))) { $0.digest = .weekly } )

56:08

And let’s run tests to make sure everything still passes. And they do! Next time: the point

56:09

We think this is pretty incredible. We only added about 50 lines of library code, consisting of a new type, a higher-order reducer, and a few helpers, and we now have a really robust solution to simple and complex forms in the Composable Architecture. We are free to add as many controls to this screen as we want, and we will not incur any additional boilerplate. The bindings will automatically update the corresponding state without us needing to do anything, but if we want to layer on some additional logic we simply need to destructure the key path we are interested in.

57:15

So we believe we have truly we have created the tools for dealing with forms in SwiftUI in a concise manner. But on Point-Free we like to end every series of episodes by asking “what’s the point?” This gives us a chance to bring abstract concepts down to earth and show some real world applications. This time we were rooted in reality from the very beginning because we started by showing that the Composable Architecture had a boilerplate problem. Then we explored a theoretical Swift feature that could help solve the boilerplate problem, that of enum cases with generics. But, even without that theoretical feature we are able to approximate it with type-erasure, and so this is a really great, real world demonstration of how to use type-erasure.

58:01

But, just because this series of episodes has been rooted in reality from the beginning doesn’t mean we can’t dig a little deeper 😁.

58:07

We are going to demonstrate how these form helpers can massively simplify and clean up a real world code base. As some of our viewers may know already, about a month ago we announced that we are working on a new project. It’s a word game called isowords , and it’s completely built in the Composable Architecture, and even the server is built in Swift. If you are interested in learning more you can visit our website at isowords.xyz , and if you’d like beta access feel free to email us .

58:37

We will live refactor the code for the settings screen in the isowords code base to show a real world example of just how concise forms and the Composable Architecture can be…next time! References Combine Schedulers: Erasing Time Brandon Williams & Stephen Celis • Jun 15, 2020 We took a deep dive into type erasers when we explored Combine’s Scheduler protocol, and showed that type erasure prevented generics from infecting every little type in our code. Note We refactor our application’s code so that we can run it in production with a live dispatch queue for the scheduler, while allowing us to run it in tests with a test scheduler. If we do this naively we will find that generics infect many parts of our code, but luckily we can employ the technique of type erasure to make things much nicer. https://www.pointfree.co/episodes/ep106-combine-schedulers-erasing-time isowords Point-Free A word game by us, written in the Composable Architecture. https://www.isowords.xyz Downloads Sample code 0133-concise-forms-pt3 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 .