My first Swift macro, and why I deleted the second one
Swift macros generate code at compile time from your source. They power @Observable, #Predicate
and @Model, and they are genuinely capable in a way property wrappers and result builders are not.
They are also the heaviest tool in the language, and I have written three: one I still use and two I deleted.
The one I kept
Generating CaseIterable-style metadata for enums with associated values, which the standard
conformance cannot do:
@CaseDetection
enum LoadState {
case idle
case loading(progress: Double)
case failed(Error)
}
// generated
extension LoadState {
var isIdle: Bool { if case .idle = self { true } else { false } }
var isLoading: Bool { if case .loading = self { true } else { false } }
var isFailed: Bool { if case .failed = self { true } else { false } }
}
That is boilerplate that grows with every case, cannot be written generically, and is exactly the kind of mechanical transformation a macro is for.
How a macro is structured
Two pieces, in two targets. The declaration in your library:
@attached(extension, names: arbitrary)
public macro CaseDetection() = #externalMacro(
module: "MyMacrosImplementation",
type: "CaseDetectionMacro"
)
And the implementation, which is a compiler plugin operating on syntax trees:
import SwiftSyntax
import SwiftSyntaxMacros
public struct CaseDetectionMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
throw MacroError.notAnEnum
}
let cases = enumDecl.memberBlock.members
.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
.flatMap(\.elements)
let properties = cases.map { element in
let name = element.name.text
return "var is\(name.capitalized): Bool { if case .\(name) = self { true } else { false } }"
}
return [try ExtensionDeclSyntax("extension \(type)") {
for property in properties { DeclSyntax(stringLiteral: property) }
}]
}
}
The implementation target runs on your machine at compile time, not on the device. It is an ordinary Swift program that takes syntax and returns syntax.
Tip
Editor → Expand Macro in Xcode shows the generated code, and it is the only reasonable way to
debug one. You can also unit-test a macro directly with assertMacroExpansion from
SwiftSyntaxMacrosTestSupport — comparing input source to expected output, without building an
app.
The two I deleted
A logging macro. #log("message") expanding to include file, line and function. That is what
#file, #line and #function default arguments already do, in one function, with none of the
build cost:
func log(_ message: String,
file: String = #file,
line: Int = #line,
function: String = #function) { … }
I had reached for a macro because it felt like the modern tool. The function is better in every way.
A dependency-injection macro generating initialisers from properties. It worked, and it made every one of those types impossible to understand without expanding the macro. The generated initialiser was four lines that I had removed from the file where a reader would look for them.
That is the failure mode worth naming: a macro moves code out of the file and into a mental model
of what the macro does. For @Observable, that trade is obviously worth it — everyone knows what
it does and it replaces a lot. For a four-line initialiser, it is not.
The costs
Build time. The macro implementation is a separate package that must be built before anything using it compiles, and expansion runs per use site. On a large codebase this is noticeable.
A hard dependency on swift-syntax. It is large and its version is tied to the compiler’s.
Trust prompts. Xcode asks users to trust macro plugins, which surprises people using your library.
Debugging. A compile error in generated code points at the macro’s use site with a message about code you did not write.
The rule I ended up with
In order of preference: a function, then a protocol extension, then a property wrapper, then a result builder, then a macro. Only reach for the macro when everything above it genuinely cannot express the thing.
The specific test: does this generate code that varies with the shape of a declaration? Reading an enum’s cases, a struct’s stored properties, a protocol’s requirements — that is macro territory, because no other tool can see the declaration’s structure.
If the generated code is the same every time, it is a function. If it varies only by type, it is a generic function. Both of those are cheaper, more readable, and do not need a plugin.