iosNudge

What are Swift macros, and how do they work?

HardSwiftTooling 3 min read

TL;DR

A macro writes code for you at compile time. It reads your source code and adds new code. There are two kinds: freestanding (#name) and attached (@Name). Macros were added in Swift 5.9. @Observable, @Model, #Preview and #expect are all macros.

Freestanding macros: #name

A freestanding macro stands alone. It produces an expression or declarations.

import Testing

@Test func sum() {
    #expect(1 + 1 == 2)     // a freestanding expression macro
}

#Preview {
    ContentView()           // generates a preview type
}

#expect can show the values on both sides when the test fails. A normal function could not see the source code of its arguments.

Attached macros: @Name

An attached macro is placed on a declaration and adds code to it.

import Observation

@Observable
final class CartModel {
    var items: [String] = []
    var total = 0.0
}

@Observable expands to tracking code. Roughly: it rewrites each stored property to call access and withMutation on a registrar, and adds Observable conformance. You write five lines. The macro writes about fifty.

Kinds of attached macros

RoleWhat it addsExample
peerNew declarations next to this one@AddAsync adding an async version of a function
accessorget/set for a propertyObservation tracking
memberNew members inside a typeA generated init
memberAttributeAttributes on each memberAdding @ObservationTracked to properties
extensionProtocol conformances via an extensionextension CartModel: Observable
bodyA function body (Swift 6)Logging or tracing wrappers

One macro can play several roles. @Observable uses member, memberAttribute and extension.

How a macro is built

A macro has two parts:

  1. Declaration — what users see:
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
    #externalMacro(module: "MyMacrosPlugin", type: "StringifyMacro")
  1. Implementation — a Swift type in a separate compiler plugin. It uses the SwiftSyntax library to read and create code.
import SwiftSyntax
import SwiftSyntaxMacros

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let arg = node.arguments.first?.expression else {
            fatalError("missing argument")
        }
        return "(\(arg), \(literal: arg.description))"
    }
}

// Usage:
let (value, code) = #stringify(2 + 3)   // (5, "2 + 3")

Xcode has a "Swift Macro" package template that sets this up.

Important properties

  • Compile time only. No runtime cost for the expansion itself.
  • Syntax only. A macro sees the source text of what it is attached to. It cannot look up types in other files.
  • Additive. Macros add code. They cannot delete or change your existing code (an accessor macro turns a stored property into a computed one, which is the main exception).
  • Sandboxed. The plugin cannot read files or use the network.
  • Visible. In Xcode you can right-click and choose "Expand Macro" to see the generated code.
  • Type-checked. The generated code is compiled like normal code, with normal errors.

Macros vs other tools

MacroProperty wrapperCode generation script (e.g. Sourcery)
WhenCompile timeRun timeBefore build
SeesSyntax of one declarationOnly the valueThe whole project
Output visible?Expand in XcodeNo extra codeGenerated files

Costs and risks

  • Macro packages depend on swift-syntax. Building it can add build time.
  • Third-party macros run code during your build. Xcode asks you to trust them first.
  • Bad diagnostics make macros hard to use. Good macros report clear errors with context.diagnose.

Common follow-up questions

  • Why did Apple replace ObservableObject with the @Observable macro? The macro tracks reads per property, so SwiftUI only updates views that read the changed property. No @Published needed.
  • Can a macro add stored properties to an existing type? A member macro can add members to the type it is attached to. A normal extension still cannot.
  • How do you test a macro? With assertMacroExpansion from SwiftSyntaxMacrosTestSupport. You compare the input source with the expected expanded source.
  • Is #available a macro? No. It is a compiler directive. #if, #available and #selector existed before macros.
  • Can a macro be used across platforms? Yes. The plugin runs on the build machine, so it works for any target platform.