Macros
Macros let Swift generate code for you at compile time. Instead of hand-writing
repetitive boilerplate, you write a short marker like #Preview or
@Observable and the compiler expands it into real, type-checked
Swift. They power some of the most-used features in modern SwiftUI and
SwiftData — and you can build your own with SwiftSyntax.
What macros are
A macro is a compile-time transformation: it takes the code you wrote and
produces additional code, which is then compiled normally. Crucially, macros
are not text substitution like C's #define.
They operate on the program's syntax tree, the result must be valid Swift,
and everything they emit is fully type-checked. Macros never hide control
flow — they only add code; they can't delete or silently mutate what
you wrote.
- Reduce boilerplate — generate initializers, conformances, or wrappers you'd otherwise type by hand.
- Catch mistakes early — some macros validate their arguments at compile time and emit precise errors.
- Stay transparent — in Xcode you can right-click a macro and "Expand Macro" to see exactly what it generates.
Two kinds of macros
Swift macros come in two syntactic flavors, distinguished by their sigil:
| Kind | Sigil | Used as | Example |
|---|---|---|---|
| Freestanding | # | An expression or declaration on its own | #Preview, #warning, #URL |
| Attached | @ | An attribute on a declaration | @Observable, @Model, @AddCompletionHandler |
Freestanding macros (#)
A freestanding macro appears on its own and expands into an expression or a
set of declarations. You've already met #Preview. Another is the
standard library's #warning, which emits a compiler warning, and
#function/#line for source location.
func ship() {
#warning("Replace the stub before release") // shows up in the Issue navigator
print("Called from \(#function) at line \(#line)")
}
Freestanding macros are invoked with a leading #.
Attached macros (@)
An attached macro is written as an attribute on a declaration and transforms that declaration — adding members, conformances, accessors, or peers. Because it's attached to something, it has access to that declaration's structure.
@Observable
final class Counter {
var value = 0 // the macro adds the observation plumbing
}
Attached macros are written as @ attributes on declarations.
Attached macros are categorized by what they add — for example
member macros add new members, extension macros add a
conformance, accessor macros add getters/setters, and peer
macros add declarations alongside the original. A single macro like
@Observable can combine several of these roles.
How expansion works
When the compiler reaches a macro, it parses your code into a syntax tree, hands the relevant piece to the macro's implementation, and splices the returned syntax back in. Compilation then continues on the expanded code. Three properties make this safe and predictable:
-
Syntax in, syntax out
The macro receives a structured representation of your code (not raw text) and returns new syntax nodes. It can't reach outside the code it was given.
-
Additive only
A macro can only add code. It never deletes or rewrites your existing declarations, so you can always reason about what you wrote.
-
Fully checked
The generated code is type-checked like any other Swift. If a macro produces something invalid, you get a normal compiler error pointing at the macro.
Conceptually, @Observable on the Counter above
expands to something like this — observation tracking added around each
stored property, plus an Observable conformance:
final class Counter: Observable {
@ObservationTracked var value = 0
private let _$observationRegistrar = ObservationRegistrar()
// ... access() / withMutation() calls wired into value's get/set ...
}
A simplified view of what the macro generates — use Xcode's "Expand Macro" to see the real output.
Tip: In Xcode, right-click any macro use and choose Expand Macro to read the generated code inline. It's the best way to understand what a macro actually does.
Using common macros
You already use macros constantly in iOS development. Two of the most important come from SwiftUI and SwiftData.
#Preview
The freestanding #Preview macro registers a preview for the
Xcode canvas. It expands into the type Xcode looks for, so you write one
clean line instead of a verbose preview provider.
#Preview("Filled counter") {
CounterView(counter: Counter())
}
#Preview generates the preview registration behind the scenes.
@Observable
The attached @Observable macro (from the Observation framework)
makes a class observable so SwiftUI re-renders when its properties change —
replacing the older ObservableObject / @Published
pattern. In a view you just hold it with @State.
@Observable
final class Counter {
var value = 0
}
struct CounterView: View {
@State var counter = Counter()
var body: some View {
Button("Count: \(counter.value)") {
counter.value += 1 // view updates automatically
}
}
}
@Observable + @State is the modern way to wire model changes to the UI.
@Model
SwiftData's @Model macro turns a plain class into a persisted
entity — generating the storage, change tracking, and schema metadata so you
can save and query objects without writing a Core Data stack.
import SwiftData
@Model
final class Task {
var title: String
var isDone: Bool
init(title: String, isDone: Bool = false) {
self.title = title
self.isDone = isDone
}
}
@Model generates the persistence layer for a SwiftData type.
Declaring and implementing macros (SwiftSyntax)
You can write your own macros. A macro has two halves: a declaration in your app or package that gives it a name and signature, and an implementation in a separate compiler-plugin target that does the work using the SwiftSyntax library.
The declaration uses the macro keyword and the
@freestanding or @attached attribute to say what
kind it is and which target implements it:
// Declaration — names the macro and points at its implementation type
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "MyMacrosPlugin", type: "StringifyMacro")
A freestanding expression macro that returns a value and its source text.
The implementation conforms to a role protocol (here
ExpressionMacro) and builds the replacement syntax from the
arguments it receives:
import SwiftSyntax
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 {
return "()"
}
// build "(argument, \"argument source text\")"
return "(\(argument), \(literal: argument.description))"
}
}
The implementation transforms the input syntax into the output expression.
Using it then looks like an ordinary call, and expands at compile time:
let (result, code) = #stringify(2 + 3)
// result == 5, code == "2 + 3"
Note: Macro implementations run inside the compiler as a
separate plugin process. That's why they live in their own target and depend
on the swift-syntax package — they manipulate syntax trees, not
your app's runtime objects.
Watch out: Reach for a macro only when simpler tools —
functions, generics, protocol extensions, property wrappers — won't do.
Macros add a build dependency and indirection; the payoff has to be real
boilerplate elimination, like @Observable or @Model.
Wrapping up
Macros are compile-time code generators that work on syntax, add code without
rewriting yours, and produce fully type-checked Swift. They come as
freestanding (#) expressions/declarations and attached
(@) attributes. You already rely on #Preview,
@Observable, and @Model every day, and when you have
genuine repetition to eliminate, you can implement your own with SwiftSyntax.