4. Macros¶
What & why¶
A macro is code that runs at compile time and generates other Swift code. Swift macros (Swift 5.9+) are type-checked, run as separate programs using the real Swift syntax tree, and can only add to your code — they can't rewrite arbitrary code behind your back. You've been using them: @Observable, #Preview, #expect, #Predicate, @Model.
Using macros¶
Two flavours of call site:
// Freestanding macros start with #
let x = #stringify(1 + 2) // hypothetical: expands to (3, "1 + 2")
let line = #line // built-in
#warning("clean this up")
// Attached macros start with @ and decorate a declaration
@Observable class Model { var count = 0 }
In Xcode: right-click a macro use → Expand Macro to see the generated code. On the command line, swiftc -dump-macro-expansions shows expansions. Always read what a macro generates the first time you use it.
Using a third-party macro requires a package dependency; the first build asks you to trust the macro (a security prompt), because the macro implementation is a program that runs during compilation (sandboxed on macOS).
The seven roles¶
Macros are declared with a role that defines where they can appear and what they can add.
| Role | Syntax | Does |
|---|---|---|
@freestanding(expression) |
#name(...) |
produces an expression value |
@freestanding(declaration) |
#name(...) |
produces declarations |
@attached(peer) |
@Name |
adds declarations beside the one it's attached to |
@attached(accessor) |
@Name on a property |
adds get/set/willSet… |
@attached(member) |
@Name on a type |
adds members inside it |
@attached(memberAttribute) |
@Name on a type |
adds attributes to members |
@attached(extension) |
@Name on a type |
adds extensions (e.g. conformances) |
@attached(body) (and preamble) macros, added in Swift 6.0, supply or wrap function bodies.
Anatomy of a macro package¶
Create one with swift package init --type macro. You get three targets:
MyMacros/
├── Package.swift
├── Sources/
│ ├── MyMacros/ ← the *declarations* clients import (@freestanding / @attached … macro)
│ ├── MyMacrosMacros/ ← the *implementations*; a compiler plugin built on SwiftSyntax
│ └── MyMacrosClient/ ← a sample executable
└── Tests/MyMacrosTests/ ← expansion tests
Declaration (public API)¶
// Sources/MyMacros/MyMacros.swift
/// Produces both the value of the expression and its source text.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "MyMacrosMacros", type: "StringifyMacro")
Implementation (compiler plugin)¶
// Sources/MyMacrosMacros/StringifyMacro.swift
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let argument = node.arguments.first?.expression else {
fatalError("#stringify requires an argument")
}
return "(\(argument), \(literal: argument.description))"
}
}
@main
struct MyMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [StringifyMacro.self]
}
The string "(\(argument), \(literal: argument.description))" is an ExprSyntax literal: SwiftSyntaxBuilder lets you interpolate syntax nodes and string literals into source text. #stringify(2 + 3) expands to (2 + 3, "2 + 3").
A member macro, conceptually¶
@attached(member, names: named(init))
public macro AutoInit() = #externalMacro(module: "MyMacrosMacros", type: "AutoInitMacro")
The implementation walks the type's stored properties (via declaration.memberBlock.members) and emits an init assigning each one. Diagnostics (errors, warnings, fix-its) are emitted with context.diagnose(...) so misuse produces a normal compiler error at the right source location.
Naming: why macros declare names:¶
Macros that add declarations must announce the kinds of names they will introduce (named(init), prefixed(_), suffixed(Mock), arbitrary). That keeps the compiler able to resolve code without running every macro first.
Testing macros¶
swift-syntax ships SwiftSyntaxMacrosTestSupport's assertMacroExpansion:
func testStringify() {
assertMacroExpansion(
"let a = #stringify(2 + 3)",
expandedSource: #"let a = (2 + 3, "2 + 3")"#,
macros: ["stringify": StringifyMacro.self]
)
}
Macro tests are fast — they never compile the generated code, only compare text.
Build cost¶
Macros depend on the swift-syntax package, which is large. First clean builds can take minutes longer (recent toolchains and prebuilt swift-syntax options reduce this). That's the main practical argument for using macros judiciously.
When to use a macro (and when not)¶
Good fits:
- Eliminating mechanical boilerplate that follows from a declaration (conformances, initialisers, mocks, observation, serialization glue).
- Compile-time validation (#URL("https://…") that fails to compile on a malformed literal).
- Embedding source text in a value (#expect shows sub-expression values).
Poor fits: - Anything a function, generic, property wrapper, or protocol extension can do. Reach for macros last. - Hiding control flow so readers can't tell what the code does. - Heavy computation that belongs at runtime.
A useful test: would a reader be more confused by reading the generated code, or by reading the macro's output being hand-written? Macros pay off when the hand-written version is long, repetitive, and mistake-prone.
Macros you already benefit from¶
| Macro | What it replaces |
|---|---|
@Observable |
ObservableObject + @Published boilerplate |
#expect, #require |
a pile of XCTAssert… variants, with rich failure output |
@Model (SwiftData) |
schema declaration and persistence glue |
#Preview |
preview provider structs |
#Predicate |
stringly-typed predicate construction |
@Test, @Suite |
test discovery |
Common mistakes¶
- Not looking at the expansion when something behaves strangely.
- Using a macro where an extension would do, adding build time for little benefit.
- Side effects in macro implementations — they run in a sandbox and must be pure functions of the syntax.
- Forgetting
names:in the attached macro declaration, so the compiler can't see generated declarations. - Poor diagnostics: if your macro is misused, emit a clear error at the offending node instead of crashing the compiler plugin with
fatalError. - Version drift: the
swift-syntaxversion must match the toolchain range your package declares.
Exercises¶
- Generate the macro package template and inspect each target. Run
swift build, then use the sample client. - Use
swiftc -dump-macro-expansions(or Xcode's Expand Macro) on@Observableand read what it generates for a two-property class. - Write the
assertMacroExpansiontest for#stringifywith a binary expression and a function call. - Implement a
@AutoInitmember macro that creates a memberwise public initializer for a struct. Emit a diagnostic when applied to a class. - Write a freestanding
#URL("…")macro that validates at compile time and expands to a non-optionalURL. - Argue (in a paragraph) whether each of those macros could be replaced by a language feature you already know.
Recap¶
- Macros are compile-time, type-checked code generators that only add code.
- Freestanding (
#) and attached (@) macros come in several roles: expression, declaration, peer, accessor, member, extension, body. - A macro package splits declaration (public), implementation (SwiftSyntax plugin), and tests.
- Use macros for repetitive boilerplate and compile-time validation; prefer ordinary language features otherwise.