Swift Macros (Swift 5.9+) — A Practical Guide

Swift macros let you generate Swift code at compile time so you can reduce repetition, enforce consistency, and move boilerplate into reusable declarations. They are especially useful when the same patterns appear across many types, properties, or functions.

Quick answer: Swift macros are compile-time code generators. You write a macro declaration, provide an implementation in a macro plugin, and the compiler expands it into Swift before the app runs.

Difficulty: Intermediate

You'll understand this better if you know: basic Swift syntax, how functions and types are declared, and the difference between compile time and runtime.

1. What Are Swift Macros?

Swift macros are a metaprogramming feature that expands source code during compilation. Instead of writing repetitive code by hand, you can define a macro that produces that code for you in a predictable way.

In practice, macros help you express intent once and let the compiler fill in the details.

2. Why Swift Macros Matter

Macros matter because many Swift programs contain repeated patterns: conformance scaffolding, diagnostic checks, logging hooks, and derived member declarations. Macros can centralize those patterns and keep them consistent.

They also improve maintainability. If a rule changes, you update the macro implementation instead of editing dozens of call sites.

Macros are not a replacement for functions or generics. Use them when you need to generate source structure, not when ordinary Swift code already solves the problem cleanly.

3. Basic Syntax or Core Idea

Swift macros have two sides: the declaration in your app target and the implementation in a macro plugin target.

Declaration side

You declare a macro like any other Swift symbol, but the body is provided elsewhere.

@freestanding(expression)
macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro")

This declaration says that stringify is a freestanding expression macro. The compiler will ask the macro implementation in the MyMacros module to expand it.

Using the macro

Once declared, you call it with the macro syntax. The compiler replaces the call with generated code.

let value = #stringify(1 + 2)

The macro can expand this into code that returns both the value and a string representation of the expression.

4. Step-by-Step Examples

Example 1: A simple expression macro

This example shows the common "value plus description" pattern. The macro takes an expression and returns a tuple.

let pair = #stringify(2 + 3)
// pair could be (5, "2 + 3")

The important idea is that the string is based on the source expression, not on the computed result.

Example 2: A member macro that adds a helper property

Attached macros can add declarations to a type, which is useful when every model needs the same helper members.

@attached(member)
macro AddID() = #externalMacro(module: "MyMacros", type: "AddIDMacro")

@AddID
struct User {
    let name: String
}

A member macro can generate additional stored properties, computed properties, or helper methods, depending on what the macro is designed to emit.

Example 3: A macro that validates input at compile time

Macros can reject invalid source before the app runs. That makes them useful for configuration-like APIs.

let port = #validatedPort(8080)

If the argument is outside the allowed range, the macro can emit a compiler diagnostic instead of letting bad values reach runtime.

Example 4: A macro that generates coding keys

A common use case is generating repetitive type-level code, such as coding keys for a model.

@AutoCodingKeys
struct Book: Codable {
    let title: String
    let author: String
}

The macro can generate the CodingKeys enum or related helper code so you do not have to maintain it by hand.

5. Practical Use Cases

Macros are most valuable when the same code pattern appears in many places and is annoying or risky to maintain manually.

6. Common Mistakes

Mistake 1: Expecting macros to run at runtime

Macros expand during compilation, so they cannot react to values that only exist after the program starts. This is a common misunderstanding when people first see macro syntax.

Problem: The macro cannot inspect a network response, user input, or any other runtime value because the expansion happens before execution.

let value = #makeDecision(runtimeFlag)

Fix: Use a normal function for runtime logic, and reserve macros for source generation and compile-time validation.

func makeDecision(runtimeFlag: Bool) -> String {
    return runtimeFlag ? "enabled" : "disabled"
}

The corrected version works because ordinary Swift functions can use values that are only known when the app runs.

Mistake 2: Forgetting the macro implementation target

Declaring a macro is not enough. You also need a macro implementation in a separate target that the compiler can load.

Problem: If the implementation module is missing or misnamed, you may see errors such as "cannot find macro" or expansion failures during build.

macro MyMacro() = #externalMacro(module: "MissingModule", type: "MyMacroImpl")

Fix: Make sure the module name and implementation type match the actual plugin target.

macro MyMacro() = #externalMacro(module: "MyMacros", type: "MyMacroImpl")

The corrected version works because the compiler can locate the real macro implementation.

Mistake 3: Generating invalid code shape

Macro output must be valid in the syntactic context where it is inserted. A member macro cannot emit code that does not belong inside a type declaration.

Problem: If the expansion produces code in the wrong place, the compiler reports syntax or expansion errors because the generated source does not fit the surrounding declaration.

@AddMember
struct Account {
    let id: Int
}

Fix: Design the macro to emit only declarations valid for the attachment kind, such as properties or methods for a member macro.

@AddMember
struct Account {
    let id: Int

    var displayName: String {
        "Account \(id)"
    }
}

The corrected version works because the generated member is legal inside the struct body.

Mistake 4: Using a macro where a generic or protocol is simpler

Macros are powerful, but they add build complexity. If you only need behavior that Swift already models well, a macro is usually the wrong tool.

Problem: Overusing macros makes code harder to read, harder to debug, and slower to build without adding real value.

@GenerateDescription
struct Point {
    let x: Int
    let y: Int
}

Fix: Prefer a straightforward protocol or computed property when the logic is simple and does not need source generation.

struct Point: CustomStringConvertible {
    let x: Int
    let y: Int

    var description: String {
        "(\(x), \(y))"
    }
}

The corrected version works because it solves the problem with ordinary Swift, which is easier to maintain.

7. Best Practices

Use macros to remove repetition, not to hide business rules

Macros are best when they generate predictable code patterns. If the macro is making an important decision, the code becomes harder to understand.

// Good: generates repetitive structure
@AutoEquatable
struct User {
    let id: Int
    let name: String
}

This keeps the source readable while still removing boilerplate.

Keep macro output small and predictable

Large expansions can make compile times longer and diagnostics harder to follow. Small generated pieces are easier to reason about.

// Good: narrow, focused expansion
@Loggable
func fetchProfile() async throws -> Profile {
    // implementation
}

Focused macros are easier to test and easier to debug when expansion goes wrong.

Prefer clear naming and explicit intent

Macro names should tell readers what is generated or checked. Ambiguous names make code harder to use correctly.

// Good: clear intent
@AutoCodingKeys
struct Book {
    let title: String
}

Clear names reduce surprise and make macro-generated code feel like part of the language rather than a hidden trick.

8. Limitations and Edge Cases

If a macro seems to "do nothing," check the expansion output in Xcode or your build logs. The issue is often an attachment mismatch, a naming mismatch, or invalid generated syntax.

9. Practical Mini Project

Here is a small example of how a macro can remove repetitive logging code from functions. The macro is declared on the function and the implementation generates logging statements around the body.

@LogCalls
func loadProfile(id: Int) async throws -> String {
    return "Profile for \(id)"
}

In a real app, the macro could generate entry and exit logs, timing measurements, or debugging metadata. The benefit is that the function body stays focused on the business logic while the macro handles the repeated tracing code.

10. Key Points

11. Practice Exercise

Expected output: A type that gains a computed greeting property without you writing the property manually.

Hint: Focus on member generation and keep the output syntactically valid inside the type body.

Solution:

@AutoGreeting
struct Person {
    let name: String
}

// Generated member could look like this:
extension Person {
    var greeting: String {
        "Hello from Person"
    }
}

12. Final Summary

Swift macros are a compile-time tool for generating Swift source, not a runtime feature. They help remove repetition, enforce rules, and keep source code consistent when the same patterns appear across many declarations.

They are most valuable when they produce small, predictable expansions that are hard to replace with plain Swift. If a function, protocol, or generic type already solves the problem clearly, use that first. If you need source generation or compile-time validation, a macro can be an excellent fit.

As you learn macros, start by reading expansions carefully and comparing them with the source that produced them. That habit makes macro-driven code much easier to trust and maintain.