BS
BleepingSwift
Published on
7 min read
Advanced

> Understanding Swift Macros: A Practical Guide

Share:

// What_You_Will_Learn

  • Understand what Swift macros are and when to use them
  • Use built-in macros like @Observable, #Predicate, and @Test
  • Know the difference between freestanding and attached macros
  • Create a basic custom macro with SwiftSyntax

Swift macros, introduced in Swift 5.9, let you generate repetitive code at compile time. They're not string-based preprocessor macros like in C. Swift macros are proper Swift code that takes in a syntax tree and produces new syntax, with full type checking on the output. If you've used @Observable, #Predicate, or @Test, you've already been using macros without thinking about it.

Why Macros?

Before macros, reducing boilerplate in Swift meant using protocols with default implementations, property wrappers, or code generation tools like Sourcery. Each had limitations. Property wrappers can't add new members to a type. Protocol extensions can't customize behavior per property. External code generators add build complexity.

Macros fill these gaps. They can add computed properties, initializers, protocol conformances, and arbitrary code to your types, all validated at compile time.

The Two Flavors: Freestanding and Attached

Swift macros come in two forms. Freestanding macros start with # and expand inline. Attached macros start with @ and modify the declaration they're attached to.

Freestanding Macros

These create code or expressions where they appear:

Swift
// #Predicate builds type-safe filter expressions
let adultFilter = #Predicate<Person> { person in
    person.age >= 18
}

// #warning and #error generate compile-time diagnostics
#warning("This API is deprecated, migrate before 3.0")

// #stringify captures both a value and its source code
let (value, code) = #stringify(2 + 3)
// value = 5, code = "2 + 3"

The #Predicate macro is particularly useful with SwiftData and Core Data. It transforms a closure into a query expression the database can optimize, catching type errors at compile time instead of runtime.

Attached Macros

These modify declarations they're attached to. You've seen this pattern:

Swift
@Observable
class UserSettings {
    var username = ""
    var theme: Theme = .system
}

The @Observable macro expands to a significant amount of code behind the scenes. You can see the expansion in Xcode by right-clicking the macro and selecting "Expand Macro." For @Observable, it adds observation tracking to every stored property, conformance to the Observable protocol, and the internal registrar that SwiftUI uses to know when to re-render.

Other common attached macros include:

Swift
// @Model from SwiftData
@Model
class Trip {
    var name: String
    var destination: String
    var startDate: Date
}

// @Test from Swift Testing
@Test("Addition is commutative")
func commutativity() {
    #expect(2 + 3 == 3 + 2)
}

// @Entry for environment, container, and focus values
extension EnvironmentValues {
    @Entry var accentColor: Color = .blue
}

Macro Roles

Each macro declares one or more roles that control what it can do. Understanding these helps you know what a macro is capable of:

Freestanding roles:

  • @freestanding(expression) produces a value, like #Predicate
  • @freestanding(declaration) creates new declarations

Attached roles:

  • @attached(peer) adds sibling declarations alongside the annotated one
  • @attached(accessor) adds get/set/willSet/didSet accessors
  • @attached(memberAttribute) applies attributes to all members
  • @attached(member) adds new members to a type
  • @attached(conformance) adds protocol conformances
  • @attached(body) provides or wraps a function body

A single macro can combine multiple roles. @Observable for instance uses member, memberAttribute, and conformance roles together.

Building a Custom Macro

Let's build a simple #URL macro that validates URL strings at compile time. Instead of crashing at runtime when you force-unwrap a bad URL, the compiler will catch it.

Start by creating a new Swift package. Macros live in a separate compilation plugin:

Bash
swift package init --type macro --name URLMacros

This generates a package with the right structure. The key pieces are the macro declaration (what users see) and the macro implementation (the code that runs at compile time).

The declaration goes in your library target:

Swift
@freestanding(expression)
public macro URL(_ string: String) -> URL =
    #externalMacro(module: "URLMacrosMacros", type: "URLMacro")

The implementation uses SwiftSyntax to inspect and generate code:

Swift
import SwiftSyntax
import SwiftSyntaxMacros
import Foundation

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.arguments.first?.expression,
              let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
              segments.count == 1,
              case .stringSegment(let literalSegment)? = segments.first
        else {
            throw MacroError.requiresStaticString
        }

        let urlString = literalSegment.content.text

        guard URL(string: urlString) != nil else {
            throw MacroError.invalidURL(urlString)
        }

        return "URL(string: \(argument))!"
    }
}

enum MacroError: Error, CustomStringConvertible {
    case requiresStaticString
    case invalidURL(String)

    var description: String {
        switch self {
        case .requiresStaticString:
            return "#URL requires a static string literal"
        case .invalidURL(let string):
            return "Invalid URL: \"\(string)\""
        }
    }
}

Register the macro in the plugin:

Swift
import SwiftCompilerPlugin
import SwiftSyntaxMacros

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

Now you can use it:

Swift
let url = #URL("https://api.example.com/v1/users")
// Compiles to: URL(string: "https://api.example.com/v1/users")!

let bad = #URL("not a url %%%")
// Compile error: Invalid URL: "not a url %%%"

The URL is validated during compilation. No more runtime surprises from malformed URL strings.

Testing Macros

Swift Testing works well for macro testing. The assertMacroExpansion function from SwiftSyntaxMacrosTestSupport checks that your macro produces the expected output:

Swift
import SwiftSyntaxMacrosTestSupport
import Testing

@Suite struct URLMacroTests {
    @Test func validURL() {
        assertMacroExpansion(
            """
            #URL("https://example.com")
            """,
            expandedSource: """
            URL(string: "https://example.com")!
            """,
            macros: ["URL": URLMacro.self]
        )
    }
}

When to Use Macros

Macros are powerful but add complexity. Use them when you have genuinely repetitive boilerplate that can't be solved with protocols, generics, or property wrappers. Good candidates include conformance generation (like Codable but for custom protocols), compile-time validation (like the URL example), and reducing repetitive accessor or observation code.

If a property wrapper or protocol extension solves your problem, prefer that. Macros require a separate compiler plugin package, which adds build time and maintenance cost. They're the right tool when you need to transform code structure in ways that other Swift features can't express.

For more on macros, the WWDC 2023 sessions Write Swift macros and Expand on Swift macros cover the full design and implementation process. The SwiftSyntax documentation is the reference for working with the syntax tree.

// Frequently_Asked

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.