Skip to content

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-syntax version must match the toolchain range your package declares.

Exercises

  1. Generate the macro package template and inspect each target. Run swift build, then use the sample client.
  2. Use swiftc -dump-macro-expansions (or Xcode's Expand Macro) on @Observable and read what it generates for a two-property class.
  3. Write the assertMacroExpansion test for #stringify with a binary expression and a function call.
  4. Implement a @AutoInit member macro that creates a memberwise public initializer for a struct. Emit a diagnostic when applied to a class.
  5. Write a freestanding #URL("…") macro that validates at compile time and expands to a non-optional URL.
  6. 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.

Next: Concurrency: async/await & structured concurrency →