Home

My first Swift macro, and why I deleted the second one

Swift macros generate code at compile time from your source. They power @Observable, #Predicate and @Model, and they are genuinely capable in a way property wrappers and result builders are not.

They are also the heaviest tool in the language, and I have written three: one I still use and two I deleted.

The one I kept

Generating CaseIterable-style metadata for enums with associated values, which the standard conformance cannot do:

@CaseDetection
enum LoadState {
    case idle
    case loading(progress: Double)
    case failed(Error)
}

// generated
extension LoadState {
    var isIdle: Bool { if case .idle = self { true } else { false } }
    var isLoading: Bool { if case .loading = self { true } else { false } }
    var isFailed: Bool { if case .failed = self { true } else { false } }
}

That is boilerplate that grows with every case, cannot be written generically, and is exactly the kind of mechanical transformation a macro is for.

How a macro is structured

Two pieces, in two targets. The declaration in your library:

@attached(extension, names: arbitrary)
public macro CaseDetection() = #externalMacro(
    module: "MyMacrosImplementation",
    type: "CaseDetectionMacro"
)

And the implementation, which is a compiler plugin operating on syntax trees:

import SwiftSyntax
import SwiftSyntaxMacros

public struct CaseDetectionMacro: ExtensionMacro {
    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        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 properties = cases.map { element in
            let name = element.name.text
            return "var is\(name.capitalized): Bool { if case .\(name) = self { true } else { false } }"
        }

        return [try ExtensionDeclSyntax("extension \(type)") {
            for property in properties { DeclSyntax(stringLiteral: property) }
        }]
    }
}

The implementation target runs on your machine at compile time, not on the device. It is an ordinary Swift program that takes syntax and returns syntax.

Tip

Editor → Expand Macro in Xcode shows the generated code, and it is the only reasonable way to debug one. You can also unit-test a macro directly with assertMacroExpansion from SwiftSyntaxMacrosTestSupport — comparing input source to expected output, without building an app.

The two I deleted

A logging macro. #log("message") expanding to include file, line and function. That is what #file, #line and #function default arguments already do, in one function, with none of the build cost:

func log(_ message: String,
         file: String = #file,
         line: Int = #line,
         function: String = #function) { … }

I had reached for a macro because it felt like the modern tool. The function is better in every way.

A dependency-injection macro generating initialisers from properties. It worked, and it made every one of those types impossible to understand without expanding the macro. The generated initialiser was four lines that I had removed from the file where a reader would look for them.

That is the failure mode worth naming: a macro moves code out of the file and into a mental model of what the macro does. For @Observable, that trade is obviously worth it — everyone knows what it does and it replaces a lot. For a four-line initialiser, it is not.

The costs

Build time. The macro implementation is a separate package that must be built before anything using it compiles, and expansion runs per use site. On a large codebase this is noticeable.

A hard dependency on swift-syntax. It is large and its version is tied to the compiler’s.

Trust prompts. Xcode asks users to trust macro plugins, which surprises people using your library.

Debugging. A compile error in generated code points at the macro’s use site with a message about code you did not write.

The rule I ended up with

In order of preference: a function, then a protocol extension, then a property wrapper, then a result builder, then a macro. Only reach for the macro when everything above it genuinely cannot express the thing.

The specific test: does this generate code that varies with the shape of a declaration? Reading an enum’s cases, a struct’s stored properties, a protocol’s requirements — that is macro territory, because no other tool can see the declaration’s structure.

If the generated code is the same every time, it is a function. If it varies only by type, it is a generic function. Both of those are cheaper, more readable, and do not need a plugin.