I’m an iOS Engineer and Engineering Manager with a passion for building quality products.
07 July 2023
Last month, WWDC came packed with exciting new developments for the iOS community, as it always does. Every year, one of the most anticipated talks for me is What’s new in Swift, because along with writing iOS apps, I like to leverage Swift for server-side projects too.
With this WWDC comes Swift 5.9, and I’d describe the set of changes overall as somewhat low-level and tough to grasp. I don’t anticipate using the ownership APIs or C++ interoperability, and while I’m sure I’ll discover a use case for type parameter packs, there is a lot of new syntax to process.
Macros are definitely nontrivial to grasp as well, but the value proposition to developers is pretty clear - extend the compiler in useful ways that previously required custom code generation tooling. Historically, when iOS developers have found repetitive boilerplate that existing tools can’t help with automating, Sourcery had been the best tool for the job. Both Sourcery and the new macro system fundamentally work by allowing you to examine your code as an abstract syntax tree and output some new Swift code to be compiled. Even as the Swift compiler improves, there are still a bunch of common use cases for such a tool:
If you want to learn more about macros, their benefits, and how they work, you should watch the WWDC session Expand on Swift macros first, and then Write Swift macros.
There’s already one mainstay use case for macros in most of my projects, and to explain why I find it useful, I need to back up and talk about a stylistic discussion that occurs a lot in the context of Swift projects.
The situation I want to examine is when a Swift project has a data type with a bunch of preset values. That’s (intentionally) a pretty vague description, and examples include:
The list of examples is quite long. And the related style discussion that comes up is: should the data type be a struct or an enum? Both of the following definitions have the same callsite and the same usage semantics.
enum AppTheme {
// …
case metal
case skyBlue
// …
var name: String {
switch self {
// …
case .metal: return "Metal"
case .skyBlue: return "Sky Blue"
// …
}
}
var headingColor: String {
switch self {
// …
case .metal: return "000000"
case .skyBlue: return "33FFFF"
// …
}
}
}
and:
struct AppTheme {
let name: String
let headingColor: String
init(name: String, headingColor: String) {
self.name = name
self.headingColor = headingColor
}
// …
static let metal = AppTheme(name: "Metal", headingColor: "000000")
static let skyBlue = AppTheme(name: "Sky Blue", headingColor: "33FFFF")
// …
}
can both be called identically (for example):
print(AppTheme.metal.name)
Enums have many valid use cases and are a critical part of the Swift language, but in the above described situations, I would say the struct is preferable here. I’m going to keep the “why” brief, because there already exist some good resources on this topic, but:*
case
inside of var name: String
and then navigate to the case
inside of var headingColor: String
- which sounds like a small deal, but at scale, these value types will often have many more than 2 properties, and many more than 2 cases. With a struct, all of those properties are localized together.init
private, or you can customize which properties are directly instantiable. With enums, you can add a case if you’re inside the owning module, and not otherwise.var
s. With structs, all you can do is declare the type.For more on this choice and why I advocate for structs, see Matt Diephouse’s post.
While I’ve established that I think structs are the right tool for this job, regardless of implementation, it will often be valuable to iterate over this set of static values. Maybe you want to show a modal UI with all of the supported AppTheme
values, or list the supported countries, or you want to snapshot test the JSON representation of all of the user roles. Admittedly, this is the one area where enum
s have a leg up - Apple supports this out of the box by just conforming to CaseIterable:
// only works for free if using an enum!
extension AppTheme: CaseIterable {}
print(AppTheme.allCases.map(\.name))
So, how do we patch this gap in functionality with structs?1 This is a perfect use case for (historically) Sourcery and (now) Swift macros! If the above examples don’t sound valuable, I’ll be clear that I find this a very valuable piece of functionality because 1) every app always has these data types with a bunch of preset values and 2) I find they very often end up being representable as JSON, some UI, or both. Being able to iterate over the set means you can get new JSON snapshot tests, UI snapshot tests, SwiftUI previews and more, without any additional work when you add a new value.
So, that’s why I have a StaticMemberIterable
in most of my projects.
StaticMemberIterable
As mentioned above, this is a good example of a gap in current compiler functionality, which we can patch up via code generation - either via Sourcery, or, starting with Xcode 15, Swift macros.
You can see my Sourcery implementation of StaticMemberIterable
here as a gist. If you’re looking to install it but aren’t familiar with Sourcery, you should give the documentation in Sourcery’s repo a read first. You’ll see that this template works with Sourcery’s annotations system, which means you just need to add a comment above the struct to opt-in to the StaticMemberIterable
code generation:
// sourcery:StaticMemberIterable
struct AppTheme {
// …
}
The template could easily be tweaked to make it work by conforming your struct type to a protocol instead, however: the template is a .swifttemplate
file, which, while documented, is not as easy to work with or diagnose errors as regular Swift, so I don’t iterate on it much - that’s one of the handful of reasons why macros are a big improvement here.
As far as the Macro-version of the implementation, I started using Ian Keen’s MacroKit, and the outputted code is functionally identical as before.
So, why is the Macro implementation so much better?
StaticMemberIterable
implementation (not that I’ve gotten around to that part!).I should also note the one major advantage that Sourcery still has over Swift macros at this time: Sourcery will discover all declarations in the Swift files that are specified in the shell prompt or YAML file, whereas macros only have visibility into the declaration they are attached to. That means that putting the static members in an extension like:
@StaticMemberIterable struct AppTheme {
// …
}
// ⚠️
extension AppTheme {
// …
static let metal = AppTheme(name: "Metal", headingColor: "000000")
static let skyBlue = AppTheme(name: "Sky Blue", headingColor: "33FFFF")
// …
}
will not include metal
and skyBlue
in its generated allCases
, because the @StaticMemberIterable
macro only applies to the declaration it is attached to (the struct, not the extension). The Sourcery approach, however, would include those values in the generated code, because it discovers all declarations within the scoped files.
StaticMemberIterable
is a good addition to a lot of projects, and I’m excited to see developers push other brand new compiler capabilities with macros. A few things on this topic I’m still thinking about:
@ViewFromStaticMemberIterable
Macro that must be attached to a SwiftUI View, provides a type that conforms to StaticMemberIterable
, and produces snapshots for each instance2. I could write a single XCTestCase
that iterates over them in a for
loop, but test failure reporting is subpar that way - all failures point to the same line and it’s not clear which instances had their tests fail. Better to generate a whole XCTestCase
with a test function per instance.
StaticMemberIterable
to include values produced by static functions, if their only argument is also CaseIterable
or StaticMemberIterable
:@StaticMemberIterable(recursive: true)
struct AppTheme {
static let metal: AppTheme = // …
static let skyBlue: AppTheme = //…
// Improve `StaticMemberIterable` to produce cases like
// `.promotionTheme(.holiday), .promotionTheme(.anniversary), etc.
// for this function if `PromotionKind` is `CaseIterable` or `StaticMemberIterable`,
// and if `recursive` is true.
static func promotionTheme(promotionKind: PromotionKind) -> AppTheme {
// …
}
}
This would be a substantial test of the SwiftSyntax macros API, but I believe should be possible.
That’s all for now on this topic. In addition to the resources linked above, I also recommend checking out the swift-macro-examples repo if you’re just getting started with this new language feature.
Of course, you can implement static var allCases: [Self]
for such a struct manually. But that means accepting that, on a project at scale with multiple developers working in the project, static members will probably be forgotten and left out of this declaration. It also means possible merge conflicts when multiple developers manually edit the declaration. ↩
I’m a big proponent of snapshot testing. There is enough to be said on snapshot testing to merit a separate post, but in short, I think it’s a low-effort, high-reward way of adding test coverage to iOS apps. I am a fan of PointFree’s snapshot testing framework. If you already use snapshot tests in your project, I would say the likelihood increases that StaticMemberIterable
is useful to you in some capacity. ↩