← 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 or a CodingKeys enum, 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

You debug macro expansions by reading the generated source, not by stepping through the macro implementation at the call site. Xcode's "Expand Macro" action (or swift build with the expansion flags) shows 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