Swift Macros: How They Work, How to Read Them, and How to Write Your Own
Every time you type @Model on a SwiftData class or @Observable on a view model, something unusual happens: the
compiler runs a Swift program that reads your source code as a syntax tree and writes new source code back into your
file — before a single line of your app compiles. That’s not a property wrapper. That’s a macro.
This post covers everything you need to go from macro consumer to macro author: how the macro system works, how to
expand macros in Xcode to see what they actually generate, and how to write and test your own macros using
SwiftSyntax. Macro testing infrastructure and the @attached role system
are covered in full.
Contents
- The Problem Macros Solve
- Types of Macros: Freestanding vs Attached
- Expanding Macros in Xcode
- Under the Hood: SwiftSyntax and the Compiler Plugin Architecture
- Building a Freestanding Macro:
#stringify - Building an Attached Macro:
@AutoEquatable - Macro Roles: The Full Taxonomy
- Advanced Usage: Peer Macros and Accessor Macros
- Testing Macros with Swift Testing and SwiftMacroTesting
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem Macros Solve
Before macros, eliminating boilerplate meant choosing between property wrappers (limited to a fixed transformation of a
stored property), code generation tools (external, not integrated with the compiler), or just typing the same thing over
and over. Consider a studio pipeline model where every entity needs Equatable conformance for diffing:
struct PixarFilm {
let title: String
let releaseYear: Int
let director: String
let boxOfficeMillions: Double
}
// Manually synthesised — Swift does this for free on structs.
// But for a class with stored properties + computed properties
// + properties you want to EXCLUDE from equality, you write this:
extension PixarFilm: Equatable {
static func == (lhs: PixarFilm, rhs: PixarFilm) -> Bool {
lhs.title == rhs.title &&
lhs.releaseYear == rhs.releaseYear &&
lhs.director == rhs.director &&
lhs.boxOfficeMillions == rhs.boxOfficeMillions
}
}
For a struct with compiler-synthesised Equatable, this is free. But the moment you add a class, an
@unchecked Sendable payload, or a property that should be excluded from equality (like a cachedPosterURL), you’re
back to typing == by hand for every type. Across a production codebase with 40 model types, that’s hundreds of lines
of code that provide zero signal — pure noise.
Macros solve this by generating the boilerplate at compile time, from a declaration you write once.
// With a macro, the equality implementation is generated for you
@AutoEquatable
class PixarFilmStore {
var films: [PixarFilm]
var lastFetched: Date
@ExcludeFromEquality var cachedPosterURLs: [String: URL]
}
The @AutoEquatable macro expands to the full Equatable implementation before the compiler sees your type. You see
clean code; the compiler sees complete code.
Types of Macros: Freestanding vs Attached
Swift macros come in two flavours, distinguished by their syntax.
Freestanding macros use the # sigil. They stand alone as expressions or declarations — they’re not attached to any
specific type or member.
// #Preview is a freestanding declaration macro
#Preview("Pixar Film Card") {
FilmCardView(film: .init(title: "Up", releaseYear: 2009))
}
// #require is a freestanding expression macro (Swift Testing)
let film = try #require(await filmStore.film(id: "up"))
// A custom freestanding expression macro
let description = #stringify(2 + 3)
// Expands to: ("2 + 3", 5)
Attached macros use the @ sigil and are attached to a declaration — a type, property, function, or extension.
// @Model is an attached macro (SwiftData) — iOS 17+
@available(iOS 17.0, macOS 14.0, *)
@Model
class PixarFilm {
var title: String
var releaseYear: Int
init(title: String, releaseYear: Int) {
self.title = title
self.releaseYear = releaseYear
}
}
// @Observable is an attached macro (Observation framework) — iOS 17+
@available(iOS 17.0, macOS 14.0, *)
@Observable
class PixarFilmStore {
var films: [PixarFilm] = []
var isLoading: Bool = false
}
The key difference: freestanding macros produce standalone expressions or declarations; attached macros augment an existing declaration by adding members, conformances, or wrapping property accessors.
Expanding Macros in Xcode
Before writing your own macros, get comfortable reading what existing macros generate. Xcode makes this easy.
- Open any file using
@Observableor@Model. - Right-click on the macro attribute (e.g.,
@Observable). - Select Expand Macro from the context menu.
Xcode will inline the generated code as a read-only expansion. Here’s what @Observable generates on a PixarFilmStore
class:
// Your source:
@Observable
class PixarFilmStore {
var films: [PixarFilm] = []
var isLoading: Bool = false
}
// After "Expand Macro", Xcode shows the generated code:
class PixarFilmStore {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<PixarFilmStore, Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<PixarFilmStore, Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
@ObservationIgnored private var _films: [PixarFilm] = []
var films: [PixarFilm] {
get {
access(keyPath: \.films)
return _films
}
set {
withMutation(keyPath: \.films) { _films = newValue }
}
}
@ObservationIgnored private var _isLoading: Bool = false
var isLoading: Bool {
get {
access(keyPath: \.isLoading)
return _isLoading
}
set {
withMutation(keyPath: \.isLoading) { _isLoading = newValue }
}
}
}
extension PixarFilmStore: Observable {}
This reveals exactly what @Observable is doing: it rewrites every stored property into a computed property backed by a
private underscore-prefixed stored property, and calls into ObservationRegistrar on every access and mutation. That’s
how SwiftUI knows to re-render when a property changes.
Under the Hood: SwiftSyntax and the Compiler Plugin Architecture
Macros are implemented as compiler plugins — separate Swift executables that the compiler forks at build time. When the compiler encounters a macro application, it:
- Serialises the relevant portion of the AST (Abstract Syntax Tree) to a binary format.
- Sends it to the plugin process via IPC.
- The plugin uses SwiftSyntax to parse and transform the AST.
- The plugin sends the generated source back to the compiler.
- The compiler inserts the generated code and continues type-checking.
This architecture has important implications:
- Macros run in a sandbox. They cannot read from disk, make network calls, or access environment variables. This is intentional — macros must be deterministic and side-effect-free.
- Macros are type-safe inputs. The input is a fully-parsed syntax tree, not raw text. You can’t accidentally produce malformed Swift — SwiftSyntax will catch structural errors.
- Build performance. Macro expansion happens in a separate process. Heavily-used macros can add perceptible build time, especially in clean builds. This is rarely a problem in practice but worth knowing.
The system was introduced in SE-0382 (expression macros), SE-0389 (attached macros), and demonstrated at WWDC 2023, Session 10166: Write Swift macros.
Note: Macros require Swift 5.9 / Xcode 15+ and are available on all Apple platforms. They do not have an iOS version minimum — the macro runs at compile time on your Mac, not at runtime on the device.
Building a Freestanding Macro: #stringify
The canonical first macro is #stringify, which takes an expression and returns a tuple of the source code string and
the evaluated result. It’s included in Xcode’s macro template for good reason: it’s the simplest possible complete
macro.
Create a new Swift Package with the macro template (File → New → Package → Swift Macro). You’ll get three targets:
PixarMacros— the public declarationPixarMacrosImpl— the compiler plugin (runs at build time)PixarMacrosClient— a test client
In PixarMacros/Sources/PixarMacros/PixarMacros.swift, declare the macro:
// The public API — this is all consuming code sees
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "PixarMacrosImpl", type: "StringifyMacro")
In PixarMacrosImpl/Sources/PixarMacrosImpl/StringifyMacro.swift, implement it:
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// node.arguments contains the arguments passed to the macro
guard let argument = node.arguments.first?.expression else {
throw MacroError.missingArgument
}
// Build the tuple expression: (originalExpression, "sourceText")
// argument.description gives us the source text of the expression
return "(\(argument), \(literal: argument.description))"
}
}
enum MacroError: Error, CustomStringConvertible {
case missingArgument
var description: String {
switch self {
case .missingArgument: return "#stringify requires an argument"
}
}
}
@main
struct PixarMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [StringifyMacro.self]
}
Now the macro works in client code:
import PixarMacros
let (result, source) = #stringify(1 + 2 + 3)
print(result) // 6
print(source) // "1 + 2 + 3"
let (filmCount, expression) = #stringify(films.filter { $0.releaseYear > 2000 }.count)
// filmCount: Int (the evaluated count)
// expression: "films.filter { $0.releaseYear > 2000 }.count"
The macro expands to (1 + 2 + 3, "1 + 2 + 3") — a tuple that carries both the value and its source representation.
Useful for assertion messages, logging, and debugging.
Building an Attached Macro: @AutoEquatable
An attached macro that synthesises Equatable conformance demonstrates the @attached(extension) and
@attached(member) roles. This macro inspects the stored properties of a type and generates a == implementation that
compares them all.
Declare the macro:
// The macro adds an Equatable conformance and the == implementation
@attached(extension, conformances: Equatable)
@attached(member, names: named(==))
public macro AutoEquatable() =
#externalMacro(module: "PixarMacrosImpl", type: "AutoEquatableMacro")
Implement it in the plugin:
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct AutoEquatableMacro: MemberMacro, ExtensionMacro {
// MARK: - MemberMacro: generates the == function
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Collect stored property names, excluding @ExcludeFromEquality
let storedProperties = declaration.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { variable in
// Exclude computed properties (they have no accessor block)
variable.bindings.contains { binding in
binding.accessorBlock == nil
}
}
.filter { variable in
// Exclude properties marked @ExcludeFromEquality
!variable.attributes.contains { attr in
attr.as(AttributeSyntax.self)?.attributeName
.as(IdentifierTypeSyntax.self)?.name.text == "ExcludeFromEquality"
}
}
.compactMap { variable -> String? in
variable.bindings.first?.pattern
.as(IdentifierPatternSyntax.self)?.identifier.text
}
// Build the comparison expression: lhs.prop1 == rhs.prop1 && ...
let comparisons = storedProperties
.map { "lhs.\($0) == rhs.\($0)" }
.joined(separator: " &&\n ")
let equalsFunctionSource: DeclSyntax = """
public static func == (lhs: Self, rhs: Self) -> Bool {
\(raw: comparisons)
}
"""
return [equalsFunctionSource]
}
// MARK: - ExtensionMacro: adds Equatable conformance
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
let equatableExtension: DeclSyntax =
"extension \(type.trimmed): Equatable {}"
guard let extensionDecl = equatableExtension.as(ExtensionDeclSyntax.self) else {
return []
}
return [extensionDecl]
}
}
Usage in production code:
@AutoEquatable
class PixarDirector {
var name: String
var filmsDirected: [String]
var awardsCount: Int
@ExcludeFromEquality var cachedHeadshotURL: URL?
init(name: String, filmsDirected: [String], awardsCount: Int) {
self.name = name
self.filmsDirected = filmsDirected
self.awardsCount = awardsCount
}
}
let peteDocter = PixarDirector(
name: "Pete Docter",
filmsDirected: ["Up", "Soul", "Inside Out"],
awardsCount: 4
)
let samePete = PixarDirector(
name: "Pete Docter",
filmsDirected: ["Up", "Soul", "Inside Out"],
awardsCount: 4
)
print(peteDocter == samePete) // true — cachedHeadshotURL is excluded
Macro Roles: The Full Taxonomy
Swift 5.9 ships with five attached macro roles and two freestanding roles:
| Role | Syntax | What it generates |
|---|---|---|
@freestanding(expression) | #macro(...) | An expression that produces a value |
@freestanding(declaration) | #macro(...) | One or more declarations (func, struct, etc.) |
@attached(member) | @Macro on a type | New members inside the type’s body |
@attached(memberAttribute) | @Macro on a type | Attributes added to existing members |
@attached(extension) | @Macro on a type | A new extension on the type |
@attached(peer) | @Macro on a member | New declarations alongside the annotated member |
@attached(accessor) | @Macro on a property | get/set/willSet/didSet accessors |
@Observable uses @attached(member) to inject the _$observationRegistrar and access/withMutation methods,
@attached(memberAttribute) to add @ObservationIgnored to the backing stored properties, and @attached(extension)
to add the Observable conformance.
A single macro declaration can combine multiple roles:
@attached(member, names: named(_$observationRegistrar), named(access), named(withMutation))
@attached(memberAttribute)
@attached(extension, conformances: Observable)
public macro Observable() =
#externalMacro(module: "Observation", type: "ObservationMacro")
Advanced Usage: Peer Macros and Accessor Macros
Peer macros generate new declarations alongside the annotated member, in the same scope. This is useful for generating mock or preview variants automatically:
@attached(peer, names: suffixed(Mock))
public macro GenerateMock() =
#externalMacro(module: "PixarMacrosImpl", type: "GenerateMockMacro")
// Usage:
protocol FilmRepository {
@GenerateMock
func fetchFilms() async throws -> [PixarFilm]
}
// The macro generates, alongside the protocol requirement:
// func fetchFilmsMock() async throws -> [PixarFilm] { return [] }
Accessor macros wrap a stored property’s get/set. This is how @Observable rewrites stored properties — the macro
replaces the simple stored property with a computed one backed by a private stored property:
@attached(accessor, names: named(init), named(get), named(set))
public macro Tracked() =
#externalMacro(module: "PixarMacrosImpl", type: "TrackedMacro")
// Usage:
class SceneGraph {
@Tracked var characterCount: Int = 0
}
// Expands to:
class SceneGraph {
@ObservationIgnored private var _characterCount: Int = 0
var characterCount: Int {
get { access(keyPath: \.characterCount); return _characterCount }
set { withMutation(keyPath: \.characterCount) { _characterCount = newValue } }
}
}
Warning: Accessor macros and property wrappers are mutually exclusive on the same property. Applying both produces a compile error. If you’re migrating from property wrappers to macros, remove the wrapper before adding the accessor macro.
Testing Macros with Swift Testing and SwiftMacroTesting
Macros must be tested against their expansion output. The open-source
swift-macro-testing package (by Point-Free) provides an
assertMacro function that snapshots the expanded output and fails if it changes unexpectedly.
Add it to your macro package:
// Package.swift
.testTarget(
name: "PixarMacrosTests",
dependencies: [
"PixarMacrosImpl",
.product(name: "MacroTesting", package: "swift-macro-testing"),
]
)
Write expansion tests:
import MacroTesting
import XCTest
@testable import PixarMacrosImpl
final class AutoEquatableMacroTests: XCTestCase {
override func invokeTest() {
// Record mode: set to true once to capture the snapshot, then false
withMacroTesting(
isRecording: false,
macros: ["AutoEquatable": AutoEquatableMacro.self]
) {
super.invokeTest()
}
}
func testBasicExpansion() {
assertMacro {
"""
@AutoEquatable
class PixarFilm {
var title: String
var releaseYear: Int
}
"""
} expansion: {
"""
class PixarFilm {
var title: String
var releaseYear: Int
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.title == rhs.title &&
lhs.releaseYear == rhs.releaseYear
}
}
extension PixarFilm: Equatable {}
"""
}
}
func testExcludedPropertyIsSkipped() {
assertMacro {
"""
@AutoEquatable
class PixarFilm {
var title: String
@ExcludeFromEquality var cachedURL: URL?
}
"""
} expansion: {
"""
class PixarFilm {
var title: String
@ExcludeFromEquality var cachedURL: URL?
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.title == rhs.title
}
}
extension PixarFilm: Equatable {}
"""
}
}
}
In “record” mode (isRecording: true), assertMacro writes the expansion to the test file as a snapshot. Switch to
false for subsequent runs — the test will fail if the macro output changes. This is the gold standard for macro
regression testing.
Performance Considerations
Macro expansion happens at compile time, so there’s no runtime cost for the expansion itself. The generated code, however, is subject to normal performance rules:
- Generated
Equatableconformances performO(n)comparisons — fine for most models, but be aware when using==in tight loops over large arrays. @Observable’s generated accessors call intoObservationRegistraron every property access from a SwiftUI tracking context. If you’re reading a property in a tight loop outside of a SwiftUI body, consider caching the value locally.- Compiler plugin invocations add build time. A package with 20 macro-heavy types may add 1–3 seconds to a clean build. This is generally acceptable and improves incrementally (only changed files trigger re-expansion).
Apple Docs:
Macros— Swift Standard Library
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
Generating boilerplate conformances (Equatable, Hashable, Codable) | Good macro candidate — the pattern is mechanical and repetitive |
| Adding observation/tracking to stored properties | Use @Observable (already solved); write a macro if you need a custom tracking system |
| Code that varies based on runtime values | Not a macro — macros are compile-time only |
Generating #Preview declarations for every component | #Preview is already a macro; use it |
| Replacing property wrappers with cleaner syntax | Good candidate for complex wrappers; simple wrappers may not justify the complexity |
| A 3-line computed property | Avoid — the macro infrastructure is heavier than the boilerplate you’re eliminating |
| Generating test doubles / mocks from protocols | Excellent candidate for @attached(peer) |
| Anything requiring network access or file I/O during expansion | Not possible — macros run in a sandbox with no I/O |
| Team unfamiliar with SwiftSyntax | Defer — macros require SwiftSyntax knowledge; a property wrapper may be simpler for now |
Summary
- Macros are compiler plugins — they run at build time, receive your source as an AST via SwiftSyntax, and write new Swift code back into the compilation unit.
- Freestanding macros (
#name) produce expressions or declarations; attached macros (@Name) augment existing declarations with new members, conformances, or property accessors. - Expand macros in Xcode by right-clicking the attribute and selecting Expand Macro — this is the fastest way to understand what any macro is generating.
- The
@attachedrole system gives precise control:memberadds declarations inside a type,extensionadds conformances,peergenerates sibling declarations,accessorrewrites property access. - Test macros by snapshot-testing their expansion output with swift-macro-testing — this catches regressions when SwiftSyntax versions change.
Macros are the foundation that @Observable and @Model are built on. For a deep dive into how @Observable
integrates with SwiftUI’s rendering pipeline and what it replaced, see
The Observation Framework.