A blog exploring advanced programming topics in Swift.

Macro Bonanza

Friday Nov 17, 2023

To celebrate the release of Swift macros we released updates to 4 of our popular libraries to greatly simplify and enhance their abilities: CasePaths, ComposableArchitecture, SwiftUINavigation, and Dependencies. Each day this week we detailed how macros have allowed us to massively simplify one of these libraries, and increase their powers.

Join us now for a recap of all the releases we had over the past week, and see just how powerful Swift macros can be.

@CasePathable

The first release of the week brought a massive update to our CasePaths library. This library aims to bring many of the affordances of key paths to the cases of enums, but sadly it never really live up to its potential. Until now, that is.

By using the new @CasePathable macro on enums you can obtain a key path for each case of the enum that abstractly represents the two fundamental things one can do with an enum value: try to extract a case’s value out of the enum, or embed a case’s value into the enum:

@CasePathable
enum Destination {
  case activity(ActivityModel)
  case settings(SettingsModel)
}

let activityPath = \Destination.Cases.activity  // CaseKeyPath<Destination, ActivityModel>

This unlocks a lot of interesting possibilities in API design, but the most immediate benefit to you is that you immediately get access to an is method for determining if an enum value matches a particular case:

let destination = Destination.activity(…)

if destination.is(\.activity) {
  …
}

Further, if you apply the @dynamicMemberLookup attribute to your enum:

@CasePathable
@dynamicMemberLookup
enum Destination { 
  …
}

…then you get instant access to a computed property for each of your enum’s cases:

let destination = Destination.activity(…)

destination.activity  // Optional(ActivityModel)
destination.settings  // nil

This gives you very easy access to the data in your enums, and you can even use key path syntax when using standard library APIs, such as compactMap:

let destinations: [Destination] = […]

let activityModels = destinations.compactMap(\.activity)

@Reducer

The second release we had this week was version 1.4 of the Composable Architecture. This release introduced the @Reducer macro that automates a few things for you:

@Reducer 
struct Feature {
  …
} 

It automatically applies the @CasePathable macro to your Action enum inside (and even State if it’s an enum), and it lints for some simple gotchas that we can detect.

But most importantly, by using @CasePathable on the feature’s enums we unlock simpler versions of the APIs offered by the library. The various compositional operators, such as Scope, ifLet, forEach, etc. can now be written with simple key path syntax:

 Reduce { state, action in 
   …
 }
-.ifLet(\.child, action: /Action.child) {
+.ifLet(\.child, action: \.child) {
   ChildFeature()
 }

The navigation view modifiers that the library provides can be massively simplified. You can now perform the state transformation using a simple, more familiar key path syntax:

 .sheet(
-  store: self.store.scope(
-    state: \.$destination, 
-    action: { .destination($0) }
-  ),
-  state: /Feature.Destination.State.editForm,
-  action: Feature.Destination.Action.editForm
+  store: self.store.scope(
+    state: \.$destination, 
+    action: { .destination($0) }
+  ),
+  state: \.editForm,
+  action: { .editForm($0) } 
 ) { store in
   EditForm(store: store) 
 }

And key paths have also allowed us to simplify how one asserts against actions received by effects while testing. Currently you must specify the exact, concrete action that is received by the test store, but now that can be shorted to the key path describing the case of the action enum:

-store.receive(.response(.success("Hello"))) {
+store.receive(\.response.success) {
   …
 }

This starts to really pay off when testing deeply nested actions, as is often the case with testing the integration of many features together:

-store.receive(.destination(.presented(.child(.response.success("Hello"))))) {
+store.receive(\.destination.child.response.success) {
   $0.message = "Hello"
 }

Better SwiftUI navigation APIs

The third release of the week updated our SwiftUINavigation library to take advantage of the @CasePathable macro. When that macro is applied to your enums describing all possible navigation destinations for a feature:

@Observable
class FeatureModel {
  var destination: Destination?
  
  @CasePathable
  enum Destination {
    case activity(ActivityModel)
    case settings(SettingsModel)
  }
  
  …
}

…then you get instant access to a simpler way of driving navigation off of that state:

-.navigationDestination(
-  unwrapping: self.$model.destination,
-  case: /FeatureModel.Destination.activity
-) { model in
+.navigationDestination(item: self.$model.destination.activity) { model in
   ActivityView(model: model) 
 }
-.sheet(
-  unwrapping: self.$model.destination,
-  case: /FeatureModel.Destination.settings
-) { model in
+.sheet(item: self.$model.destination.settings) { model in
   SettingsView(model: model)
 }

There’s no need to use a custom view modifier, and you get the benefits of Xcode autocomplete and type inference.

@DependencyClient

And finally we released an update to our Dependencies library that introduces a new @DependencyClient macro. If you design your dependencies using a struct interface rather than protocol, then you can apply this macro to your dependency like so:

@DependencyClient
struct AudioPlayerClient {
  var loop: (_ url: URL) async throws -> Void
  var play: (_ url: URL) async throws -> Void
  var setVolume: (_ volume: Float) async -> Void
  var stop: () async -> Void
}

This does a few things for you:

  • It automatically generates a default implementation of the interface that simply throws an error and triggers an XCTest failure in each endpoint. You can create this instance by doing AudioPlayerClient(), and this is the best implementation to use for testValue when registering the dependency with the library.
  • It defines methods with named arguments for each closure endpoint in the interface. This fixes one of the biggest downsides to using structs to model dependencies:
    -audioPlayer.play(URL(string: …))
    +audioPlayer.play(url: URL(string: …))
    
  • It generates a public initializer for the client type that specifies every property in the type. That means you do not need to provide this initializer yourself if you separate the interface of your dependency from its implmenetation in separate modules, as is recommended for dependencies that take a long time to compile.

Macro testing

We did not release any updates to our Macro Testing this week, but we did want to give it a special shoutout for making it possible to test each of the macros released this week. Our assertMacro tool was immensely helpful in testing all of the tiny edge cases of our macros, of which there were many.

Shortly after releasing @CasePathable it was brought to our attention that we were not handling cases with multiple wildcard argument labels, for example:

case action(_ id: Int, _ message: String)

Luckily the fix was straightforward, and in order to test the fix all one has to do is invoke assertMacro with the string of code that you want to be expanded:

func testWildcard() {
  assertMacro {
    """
    @CasePathable enum Foo {
      case bar(_ int: Int, _ bool: Bool)
    }
    """
  }
}

Then run the test, and the expanded macro code will be generated and inserted directly into the test file. This makes it incredible easy to write as many tests as it takes to get confidence that you have covered all of your bases.

And we wrote a lot of tests. For example, our Dependencies library has 40 tests for its macros, exercising every little edge case we could think of:

We are even able to write simple tests that assert our macro’s diagnostics behave how we expect. For example, the @Reducer macro performs a light touch of linting on your reducer in order to make sure you don’t accidentally implement the reduce(into:action:) requirement and body requirement at the same time. Doing so is invalid, but the Swift compiler can’t help you, whereas our macro can.

And if all of that wasn’t good enough, if we ever make a fundamental change to our macro and want to re-record all macro expansions at once, that is as easy as a single line change:

override func invokeTest() {
  withMacroTesting(
    isRecording: true,
    macros: [DependencyClientMacro.self]
  ) {
    super.invokeTest()
  }
}

By putting the test suite in “record mode”, the Macro Testing library will automatically write the freshest macro expansion into the test files.

Get started today!

That concludes this week’s Macro Bonanza! Make sure you update all of your dependencies on our libraries to take advantage of these new tools, and let us know what you think!


Subscribe to Point-Free

👋 Hey there! If you got this far, then you must have enjoyed this post. You may want to also check out Point-Free, a video series covering advanced programming topics in Swift. Consider subscribing today!