Macros

Swift · Expert · 16 min

@Observable, #Preview, #expect and @Model are macros; reading their expansions is essential for debugging.

Macros (Swift 5.9+) generate code at compile time. Unlike C macros they operate on the syntax tree, are type-checked, and only add code — they never delete or rewrite what you wrote.

Two families:

  • Freestanding macros start with # and produce an expression or declarations: #Preview { ... }, #expect(a == b), #URL("https://...")-style validators.
  • Attached macros start with @ and add to the declaration they're attached to. Roles include @attached(member) (add members), peer (add sibling declarations), accessor (add get/set), memberAttribute, and extension (add conformances). @Observable combines several roles to add observation storage and accessors to a class.

In Xcode, right-click a macro use and choose Expand Macro to see the generated code. This is the first thing to do when a macro-based API behaves unexpectedly.

A macro is declared in a library with macro and implemented in a separate compiler plug-in target built with SwiftSyntax. The implementation is a type conforming to a protocol such as ExpressionMacro or MemberMacro, receiving the syntax node and returning new syntax. It runs in a sandbox, can't access the network or files, and should emit clear diagnostics instead of generating broken code. Package templates (swift package init --type macro) set this all up, including tests using assertMacroExpansion.

Costs: SwiftSyntax adds build time, so prefer ordinary functions, generics or property wrappers when they suffice.

Example

import Observation

@Observable
final class Cart {
    var items: [String] = []
    var total: Double = 0
}
// Expanded (simplified): the macro adds an ObservationRegistrar,
// rewrites `items` and `total` with tracking accessors and adds
// `extension Cart: Observable {}`.

let cart = Cart()
withObservationTracking {
    print("count:", cart.items.count)    // count: 0 (reads are tracked)
} onChange: {
    print("items will change")
}
cart.items.append("apple")               // items will change

// Declaring a macro in a library (implementation lives in a plug-in target):
// @freestanding(expression)
// public macro stringify<T>(_ value: T) -> (T, String) =
//     #externalMacro(module: "MyMacros", type: "StringifyMacro")

Remember

  • Freestanding #macro makes expressions/declarations; attached @Macro adds to a declaration
  • Macros only add type-checked code; use Expand Macro to read it
  • Implementations are SwiftSyntax plug-ins running in a sandbox

Watch out for

  • Writing a macro for something a generic function or property wrapper already handles, paying build-time cost for nothing.

Check yourself

  1. What can a Swift macro NOT do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/

Macros · Swift · Topolearn