Boost Your Swift Code with Macros: Practical Examples and Use Cases

Swift 5.9 introduced macros, a powerful new feature that brings metaprogramming capabilities to the language. With macros, developers can write reusable code that generates other code at compile time, improving efficiency, maintainability, and reducing boilerplate. In this blog post, we’ll explore what Swift macros are, how they work, and how you can leverage them in your projects.

What Are Swift Macros?

Macros in Swift allow developers to define code transformations that are applied at compile time. They provide a way to reduce repetitive patterns in code by automating the generation of boilerplate logic. Unlike traditional preprocessor macros in C, Swift macros are type-safe, structured, and integrated into the compiler.

Swift macros are powered by SwiftSyntax, Apple’s library for parsing, analyzing, and transforming Swift source code. This ensures that macros operate within the bounds of Swift’s syntax rules, making them safer and more reliable than traditional preprocessor-based macros.

Types of Macros in Swift

There are three main types of Swift macros:

  1. Expression Macros – Replace an expression with generated code.
  2. Freestanding Macros – Expand into standalone declarations or expressions.
  3. Attached Macros – Modify existing code elements, such as struct properties or function definitions.

Setting Up a Swift Macro

To create a Swift macro, follow these steps:

1. Create a New Macro Package

Macros are defined in a separate Swift package. Use the Swift Package Manager (SPM) to create one:

swift package init --type macro

This creates a package that includes a Macros target where the macro logic will be implemented.

2. Define the Macro

Inside the Macros target, define a new macro. For example, let’s create a macro that generates a computed property for the number of elements in an array:

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

@main
struct MyMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [CountMacro.self]
}

public struct CountMacro: MemberMacro {
    public static func expansion(of node: AttributeSyntax,
                                 attachedTo declaration: some DeclSyntaxProtocol,
                                 providingMembersOf type: some TypeSyntaxProtocol,
                                 in context: MacroExpansionContext) throws -> [DeclSyntax] {
        guard let structDecl = declaration.as(StructDeclSyntax.self) else {
            throw MacroExpansionError.message("@Count can only be applied to structs")
        }
        return ["var count: Int { items.count }"].map { DeclSyntax(stringLiteral: $0) }
    }
}

3. Use the Macro in Your Code

After defining the macro, use it in a Swift file:

@Count
struct ItemCollection {
    let items: [String]
}

let collection = ItemCollection(items: ["Apple", "Banana", "Cherry"])
print(collection.count) // Outputs: 3

More Examples and Use Cases

1. Automatically Conforming to Codable

If you frequently define structs that need to conform to Codable, you can create a macro to automatically generate the necessary conformance:

@AutoCodable
struct User {
    let id: Int
    let name: String
    let email: String
}

This macro would generate the Codable conformance behind the scenes, reducing boilerplate.

2. Generating Custom Equatable Conformance

For large structs, writing Equatable manually can be tedious. A macro can generate it for you:

@AutoEquatable
struct Product {
    let id: Int
    let name: String
    let price: Double
}

The macro would expand into:

extension Product: Equatable {
    static func ==(lhs: Product, rhs: Product) -> Bool {
        return lhs.id == rhs.id && lhs.name == rhs.name && lhs.price == rhs.price
    }
}

3. Logging Function Calls Automatically

A macro can wrap function calls with logging to help with debugging:

@LogExecution
func fetchData() {
    print("Fetching data...")
}

Expands into:

func fetchData() {
    print("Function fetchData started")
    print("Fetching data...")
    print("Function fetchData ended")
}

4. Enforcing Thread Safety

If you have properties that should always be accessed from the main thread, a macro can ensure that:

@MainThreadSafe
var userData: [String: Any] = [:]

Expands into:

var userData: [String: Any] {
    get {
        assert(Thread.isMainThread, "Accessing userData from a background thread!")
        return _userData
    }
    set {
        assert(Thread.isMainThread, "Modifying userData from a background thread!")
        _userData = newValue
    }
}

Debugging Macros

Since macros generate code at compile time, debugging them requires looking at the expanded source. Use the swift-macro command-line tool to inspect the expanded macro output:

swift-macro expand SourceFile.swift

Performance Considerations

Macros execute at compile time, meaning they don’t affect runtime performance. However, excessive macro usage can slow down compilation. Use macros judiciously to balance maintainability and performance.


Conclusion

Swift macros are a game-changing feature that significantly enhances code reusability and maintainability. By leveraging compile-time code generation, you can write cleaner, more efficient Swift code. Whether you need to reduce boilerplate, enforce coding patterns, or automate repetitive tasks, macros provide an elegant and type-safe solution.

With Swift macros, metaprogramming in Swift has entered a new era—one that’s structured, powerful, and built for the modern developer. Start exploring macros today and unlock a new level of Swift development!

TOC