How it works
The explanation, with examples you can run.
Freestanding and attached
- Freestanding macros start with
#. An expression macro produces a value:#Preview { ... },#expect(a == b)in Swift Testing. Declaration macros produce new declarations. - Attached macros start with
@and add to the declaration they are written on. Each declares one or more roles:member(add members),accessor(turn a stored property into a computed one),peer(add declarations next to it),extension(add conformances),memberAttribute(put attributes on members), andbody(Swift 6.0, provide a function body).
@Observable is an attached macro with several roles. On a class:
import Observation @Observablefinal class Counter { var value = 0 var label: String { "Count: \(value)" } // computed: not tracked itself, depends on value} let counter = Counter()withObservationTracking { print(counter.label) // reading value inside here registers interest} onChange: { print("value will change")}counter.value += 1 // prints "value will change"It adds a private ObservationRegistrar, rewrites each stored property into a computed one whose getter reports the access and whose setter reports the change, and adds conformance to the Observable protocol. SwiftUI uses exactly this access tracking to know which views read which properties. In Xcode, Expand Macro on @Observable shows the generated code.
How a macro is built
A macro lives in a Swift package with two parts:
- A declaration in the library that users import:
@freestanding(expression) public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro"). - An implementation in a
.macrotarget: a type conforming to a protocol such asExpressionMacro, using SwiftSyntax to read the arguments and build the expansion.
The plug-in runs in a sandbox, without network or file access, and should be deterministic: the same input must give the same output. Errors are reported as compiler diagnostics at the use site, with fix-its if the macro provides them. Expansions can be unit-tested with assertMacroExpansion from SwiftSyntax.
Costs and cautions
- Build time. The implementation depends on swift-syntax, a large package. Building it from source used to add noticeably to clean builds; recent toolchains can use prebuilt swift-syntax, and Swift 6.3 extends that to libraries shared by several macros.
- Trust. A macro package runs code in your build. Xcode asks you to trust a macro package before it runs.
- Readability. A macro is a small language extension. Prefer a function, generic, property wrapper or result builder when they do the job; reach for a macro when you need to generate declarations or check something at compile time that the type system can't.
Common ones you'll meet: @Observable (Observation), @Model (SwiftData), #Preview (Xcode previews), and @Test and #expect (Swift Testing).
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.