Swift path · The language under SwiftUI and beyond · Topic 12 of 13

Macros

Not started

**Macros** (Swift 5.9) generate code at compile time. A macro is declared in your library like a function, but implemented by a separate program, a compiler plug-in, that receives the syntax tree of the place where it was used and returns new Swift source. The compiler inserts that source and type-checks it as if you had written it yourself. Unlike C macros there is no text substitution: inputs and outputs are well-formed Swift, and macros can only add code.

Why it matters: @Observable, #Preview, #expect and @Model are macros. Knowing how they expand makes their behaviour, their errors and their effect on build times understandable.

  1. 1Two kinds

    Freestanding macros (#name) produce an expression or declarations; attached macros (@Name) add to the declaration they are written on.

  2. 2Implemented with SwiftSyntax

    A macro is a separate compiler plug-in that receives syntax trees and returns new source; the result is type-checked like hand-written code.

  3. 3Additive and visible

    Macros can only add code, never delete or silently change yours, and Xcode can show every expansion.

1

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), and body (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:

  1. A declaration in the library that users import: @freestanding(expression) public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro").
  2. An implementation in a .macro target: a type conforming to a protocol such as ExpressionMacro, 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).

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.When does a macro's implementation run?
  2. 2.What can an attached macro such as @Observable on a class do?
  3. 3.Why do macro packages historically slow down clean builds?

0 of 3 right so far. Get all three to finish this topic.