← All shorts
61Swift 5.9+2 min read

Writing an Attached Macro

Attached macros generate code from a declaration you annotate, expanded at compile time and checked by the type checker.

Adding a boilerplate property to every model — an id, a CodingKeys enum, a logging hook — used to mean either hand-writing it everywhere or reaching for a code generation script that runs outside the build. A macro does the same expansion, but as part of compilation, with full type-checker visibility into the result.

#The new way

The expansion logic lives in a separate compiler-plugin target, built against SwiftSyntax:

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxMacros

public struct AddIdentifiableMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        ["let id = UUID()"]
    }
}

@main
struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        AddIdentifiableMacro.self,
    ]
}

The client-facing package only exposes the macro's declaration, pointing at that plugin by name:

@attached(member, names: named(id))
public macro AddIdentifiable() = #externalMacro(module: "MyMacrosPlugin", type: "AddIdentifiableMacro")

Call sites just apply the attribute:

import Foundation

@AddIdentifiable
struct Order {
    let total: Double
}

let order = Order(total: 42.0)
print(order.id)

#Why it matters

  • providingMembersOf gets the full syntax tree of Order, so the macro could inspect existing properties instead of blindly adding one
  • @attached(member, names: named(id)) declares up front exactly which names the macro can introduce, so Xcode can offer completion for id before expansion even runs
  • Expansion happens at compile time and produces real Swift source the type checker validates — a malformed expansion is a build error, not a runtime surprise
  • The macro implementation lives in a separate SwiftSyntax-based target, kept out of the app's runtime binary entirely

#Gotcha

Macro expansions are debugged by reading generated source, not by stepping through the macro implementation at the call site — use Xcode's "Expand Macro" action (or swift build with the expansion flags) to see exactly what code a given annotation produced. A macro that compiles fine in isolation can still fail at a specific call site if the declaration it's attached to doesn't match what the macro assumed (wrong property count, missing type annotation), and that failure surfaces as a diagnostic on the macro attribute, not inside the macro's own implementation file.

swiftconcurrency

Related shorts