Swift macros are production-ready: here's how to write your own
All Articles
· Swift Macros iOS Tooling

Swift macros are production-ready: here's how to write your own

Swift macros shipped in Swift 5.9 and the ecosystem spent a year learning when to use them. Many developers spent that year exploring them — including writing custom macros for production apps. This is what actually works.


When Swift macros landed at WWDC 2023, my first reaction was: this is a lot of new concepts for something that might be a solution in search of a problem. @Model from SwiftData was the obvious demo. But what about writing your own?

Answering that question takes time. Most teams can prototype several custom macros, then delete half of them once the novelty fades. The ones that survive usually eliminate a very specific class of repetitive, bug-prone code. This article distills those lessons with a January 2025 lens: macros are stable enough for production, but only for the right jobs.

Why Macros Exist

Swift macros solve a problem that protocols and generic types can’t: code generation based on the structure of a declaration. Consider two examples:

  1. You want every request enum in your networking layer to generate a canonical analytics key from case names and associated-value labels.
  2. You want each domain model to receive consistent, mechanical conformances (Equatable, Hashable, diagnostics) generated from stored properties.

Protocols can require behavior, but they cannot synthesize declaration-specific implementation details. Property wrappers can transform a single property, but they cannot inspect and generate code for an entire type. Macros can.

The tradeoff: macros have a learning curve that is genuinely steep. You’re working with SwiftSyntax, the compiler’s AST API, in a separate package that’s compiled independently of your app. Errors in your macro show up as compiler errors at the call site, and debugging usually benefits from a dedicated macro test target.

The Anatomy of a Swift Macro

Every macro has two parts: a declaration in your app module and an implementation in a separate macro implementation module.

// In your app or framework:
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(
    module: "MyMacrosPlugin",
    type: "StringifyMacro"
)
// In MyMacrosPlugin:
import SwiftSyntaxMacros
import SwiftCompilerPlugin

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

The implementation returns modified Swift syntax. You’re not executing app logic at compile time - you’re generating code that is then compiled. This distinction matters for design and debugging. Your macro runs during compilation on the developer’s machine (or CI), not during app runtime.

A Real Example: @AnalyticsKey

Here’s an example of a macro that earns its place in production. A codebase with dozens of analytics events spread across screens, reducers, and flows often needs a canonical event name derived from the enum declaration itself. The manual pattern is brittle:

// Before: hand-maintained analytics keys
enum CheckoutEvent {
    case screenViewed
    case buttonTapped(source: String)
    case purchaseCompleted(orderID: UUID, amount: Decimal)

    var analyticsKey: String {
        switch self {
        case .screenViewed:
            return "screen_viewed"
        case .buttonTapped(source: _):
            return "button_tapped.source"
        case .purchaseCompleted(orderID: _, amount: _):
            return "purchase_completed.order_id.amount"
        }
    }
}

When developers renamed a case or added a new associated-value label, they often forgot to update the analytics key. The result was messy data: dashboards split one conceptual event into multiple strings, and downstream queries quietly drifted out of sync.

The @AnalyticsKey macro generates the analyticsKey property by inspecting the enum cases and associated-value labels:

@AnalyticsKey
enum CheckoutEvent {
    case screenViewed
    case buttonTapped(source: String)
    case purchaseCompleted(orderID: UUID, amount: Decimal)
}

// Macro expands to:
enum CheckoutEvent {
    case screenViewed
    case buttonTapped(source: String)
    case purchaseCompleted(orderID: UUID, amount: Decimal)

    var analyticsKey: String {
        switch self {
        case .screenViewed:
            return "screen_viewed"
        case .buttonTapped(source: _):
            return "button_tapped.source"
        case .purchaseCompleted(orderID: _, amount: _):
            return "purchase_completed.order_id.amount"
        }
    }
}

The implementation uses EnumDeclSyntax to enumerate the cases and generate the switch:

public struct AnalyticsKeyMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
            throw MacroError.notAnEnum
        }

        let cases = enumDecl.memberBlock.members
            .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
            .flatMap(\.elements)

        let switchCases = cases.map { element in
            let caseName = element.name.text
            let labels = associatedValueLabels(for: element)
            let pattern = casePattern(for: element)
            let key = makeAnalyticsKey(caseName: caseName, labels: labels)

            return """
            case \(raw: pattern):
                return \(literal: key)
            """
        }

        let property: DeclSyntax = """
        var analyticsKey: String {
            switch self {
            \(raw: switchCases.joined(separator: "\n            "))
            }
        }
        """

        return [property]
    }
}

When a developer renames purchaseCompleted or adds a new currency label, the next compilation automatically updates the generated key. No duplicated string tables. No schema drift hiding in a forgotten switch. This turns a subtle human process bug into a compile-time mechanical process.

Testing Macros

This is the part most tutorials skip. Macros are much easier to maintain with tests written against the expanded output:

import SwiftSyntaxMacrosTestSupport

final class AnalyticsKeyTests: XCTestCase {
    func testBasicExpansion() {
        assertMacroExpansion(
            """
            @AnalyticsKey
            enum CheckoutEvent {
                case screenViewed
                case buttonTapped(source: String)
            }
            """,
            expandedSource: """
            enum CheckoutEvent {
                case screenViewed
                case buttonTapped(source: String)

                var analyticsKey: String {
                    switch self {
                    case .screenViewed:
                        return "screen_viewed"
                    case .buttonTapped(source: _):
                        return "button_tapped.source"
                    }
                }
            }
            """,
            macros: ["AnalyticsKey": AnalyticsKeyMacro.self]
        )
    }
}

assertMacroExpansion takes the input source, the expected expanded output, and the macro implementations. If the expansion doesn’t match, the test fails with a diff. In practice, this is one of the most useful ways to iterate on macro implementations — you’re debugging generated Swift source, and the diff is usually the fastest feedback loop.

One production tip: pin swift-syntax to the toolchain-compatible version and upgrade it intentionally with Swift upgrades. Macro projects often fail in CI after Xcode updates when this is left implicit.

When Not to Write a Macro

Two examples of macros that often get deleted:

@LogCalls — an attached macro that automatically logs every function entry and exit. Seems useful, but in practice, the logging noise is often worse than the benefit, and the macro can interfere with how Instruments attributes function time.

@DefaultInit — auto-generated memberwise initialisers with sensible defaults. This often just replaces a hand-written initializer that would be clearer to future readers, and the macro-generated init is less discoverable in code completion.

Macros like @AnalyticsKey work because:

  1. The generated code is mechanical and never needs customization
  2. The alternative (manual implementation) has a clear failure mode (schema drift in hand-written strings)
  3. The benefit scales with the number of enums you apply it to

If you can’t articulate a specific class of bug or toil that your macro eliminates, you probably don’t need a macro. Use a protocol extension first. Write the macro when the protocol extension isn’t enough.

The Compilation Time Question

One thing to measure before committing: macro expansion adds compilation overhead. In a real codebase, @AnalyticsKey applied to 40+ enums might add approximately 1.8 seconds to a clean build. Incremental builds are usually faster because the compiler can avoid re-expanding unchanged declarations.

That’s acceptable for the boilerplate it eliminates. But macro creation is a compiler plugin build step — if your macro package is large or complex, that build step gets expensive. Keep macro implementations focused and small.

Swift macros are a powerful tool with a real learning curve. As of early 2025, they’re production-viable for narrow, high-leverage generation tasks. The payoff is genuine when you’ve found boilerplate that’s mechanical, error-prone, and repeated across many types. For everything else, simpler language features still win.