What are Swift macros, and how do they work?
TL;DR
A macro writes code for you at compile time. It reads your source code and adds new code.
There are two kinds: freestanding (#name) and attached (@Name).
Macros were added in Swift 5.9. @Observable, @Model, #Preview and #expect are all macros.
Freestanding macros: #name
A freestanding macro stands alone. It produces an expression or declarations.
import Testing
@Test func sum() {
#expect(1 + 1 == 2) // a freestanding expression macro
}
#Preview {
ContentView() // generates a preview type
}#expect can show the values on both sides when the test fails. A normal function could not see the source code of its arguments.
Attached macros: @Name
An attached macro is placed on a declaration and adds code to it.
import Observation
@Observable
final class CartModel {
var items: [String] = []
var total = 0.0
}@Observable expands to tracking code. Roughly: it rewrites each stored property to call access and withMutation on a registrar, and adds Observable conformance. You write five lines. The macro writes about fifty.
Kinds of attached macros
| Role | What it adds | Example |
|---|---|---|
peer | New declarations next to this one | @AddAsync adding an async version of a function |
accessor | get/set for a property | Observation tracking |
member | New members inside a type | A generated init |
memberAttribute | Attributes on each member | Adding @ObservationTracked to properties |
extension | Protocol conformances via an extension | extension CartModel: Observable |
body | A function body (Swift 6) | Logging or tracing wrappers |
One macro can play several roles. @Observable uses member, memberAttribute and extension.
How a macro is built
A macro has two parts:
- Declaration — what users see:
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "MyMacrosPlugin", type: "StringifyMacro")- Implementation — a Swift type in a separate compiler plugin. It uses the SwiftSyntax library to read and create code.
import SwiftSyntax
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let arg = node.arguments.first?.expression else {
fatalError("missing argument")
}
return "(\(arg), \(literal: arg.description))"
}
}
// Usage:
let (value, code) = #stringify(2 + 3) // (5, "2 + 3")Xcode has a "Swift Macro" package template that sets this up.
Important properties
- Compile time only. No runtime cost for the expansion itself.
- Syntax only. A macro sees the source text of what it is attached to. It cannot look up types in other files.
- Additive. Macros add code. They cannot delete or change your existing code (an accessor macro turns a stored property into a computed one, which is the main exception).
- Sandboxed. The plugin cannot read files or use the network.
- Visible. In Xcode you can right-click and choose "Expand Macro" to see the generated code.
- Type-checked. The generated code is compiled like normal code, with normal errors.
Macros vs other tools
| Macro | Property wrapper | Code generation script (e.g. Sourcery) | |
|---|---|---|---|
| When | Compile time | Run time | Before build |
| Sees | Syntax of one declaration | Only the value | The whole project |
| Output visible? | Expand in Xcode | No extra code | Generated files |
Costs and risks
- Macro packages depend on
swift-syntax. Building it can add build time. - Third-party macros run code during your build. Xcode asks you to trust them first.
- Bad diagnostics make macros hard to use. Good macros report clear errors with
context.diagnose.
Common follow-up questions
- Why did Apple replace
ObservableObjectwith the@Observablemacro? The macro tracks reads per property, so SwiftUI only updates views that read the changed property. No@Publishedneeded. - Can a macro add stored properties to an existing type? A
membermacro can add members to the type it is attached to. A normal extension still cannot. - How do you test a macro? With
assertMacroExpansionfromSwiftSyntaxMacrosTestSupport. You compare the input source with the expected expanded source. - Is
#availablea macro? No. It is a compiler directive.#if,#availableand#selectorexisted before macros. - Can a macro be used across platforms? Yes. The plugin runs on the build machine, so it works for any target platform.