Using Swift macros for better DX

Vladimirs Nordholm

When migrating our app to become Swift 6 compatible, I took the opportunity to take a look into Swift macros, since our storage helper had a lot of duplicate boilerplate code.

Using macros

Mapping API data models to Core Data models included a lot of (almost) duplicate code for every method: calling code in the correct context, fetching existing (or creating new) models, saving context, etc.

I created a macro which smooths out this process, while still being type-safe. This is what a mapping method looks like:

actor Storage {
  static let shared = Storage()

  // ...

  @StorageMap
  private func store(stop: Stop, apiStop: APIStop) {
      stop.id = apiStop.id
      stop.name = apiStop.name
      stop.extra = apiStop.description
      stop.sortIndex = Int16(apiStop.sortIndex)
  }

  // ...
}

I want to put weight into the mapping being type-safe here – we are using Swift here to verify that we map correctly, and then we use a macro to generate methods with additional code but still using the body we just wrote.

With that little snippet everything we need to map our models is set up, which creates a new public method that is used like this:

let apiStop: APIStop = await  // fetch from API

await Storage.shared.store(stop: apiStop)

There is also an optional callback to set UI variables, which I’ll talk more about later.

await Storage.shared.store(stop: apiStop) { stop in
    // main actor code block, we can set UI variables
    self.someStop = stop
}

Explanation

We use the private method itself to guarantee type-safety, and we also infer from the parameters what the generated methods’ definitions should be. Essentially, we copy-paste the body of our method into some boilerplate, with the correct type definitions.

The methods must be marked private, because you are never going to call on these methods as a developer — you are only going to use the generated methods. With the time constraint I had, I couldn’t figure out how to “remove” the original method from auto completions.

Fundamentally, is actually pretty simple, but it does do a lot of heavy lifting for our code base. As a developer I can focus on building a great user experience, and less about the nit-picky details of our models in our database.

It also creates an additional method for storing multiple of a model, store(stops: [APIStop]), which takes an array of APIStop and runs the same code for every one of them.

UI-safe callbacks

The last parameter of the generated store methods is a @MainActor callback, that can be used to update @State variables in a view.

callbackInViewContext: (@MainActor @Sendable (Stop) -> Void)?

This is a huge help, since now we could store and update models directly in the same place. For instance, when creating a booking and storing the result from within a view.

Clean code. Happy developer.

Source code

I am ashamed to say, but the source code for the macro is actually pretty messy. It was written under time constraints, and has quite a few app-specific tweaks, so I will not be sharing it here. However, what we have tremendously helps the development of the app, and I would say that is a win.

If you really want to, you could take the principles I’ve mentioned here and write your own macro – it doesn’t hurt to try!