Skip to main content

Working with Swift Macros

1. Understanding Macro Types

Type Description Usage Example Notes
Expression Macro Expands to a single expression
@freestanding(expression) 
macro square(_ x: Int) -> Int = #expression(x * x)
Used for inline computations
Declaration Macro Expands to one or more declarations
@freestanding(declaration) 
macro log(_ message: String) = #declaration("print(\\(message))")
Can generate functions, variables, etc.
Member Attribute Macro Applies to members of a type
@memberAttribute 
macro observable(_ keyPath: KeyPath<Self, String>) = #attribute
Used to add behavior to properties or methods
Peer Attribute Macro Applies to a type or its members
@peerAttribute 
macro jsonEncodable() = #attribute
Can modify type definitions or their members

Example: Expression Macro Usage

let result = square(5) // Expands to: let result = 5 * 5

Example: Declaration Macro Usage

log("Hello, World!") // Expands to: print("Hello, World!")

2. Using Built-in Macros (#warning, #error)

Macro Description Usage Notes
#warning Triggers a compilation warning with a custom message. #warning("Review this implementation") Does not stop compilation, only shows a warning.
#error Triggers a compilation error with a custom message. #error("This feature is not implemented yet") Stops compilation with an error message.

Example: Using #warning and #error

struct MyStruct {
    #warning("Implement proper validation logic here")
    var value: Int = 0
    
    #error("This class is deprecated, use MyNewClass instead")
}

3. Using @Observable Macro

The @Observable macro in Swift is a powerful tool that automatically generates observable properties and change tracking for your models. It simplifies the process of making your data structures compatible with SwiftUI's view updates.

Property Description Usage
@Observable Applies to a class or struct to make its properties observable. @Observable must be placed before the class or struct declaration.

Example: Basic Usage of @Observable

@Observable
class User {
    var name: String = "John"
    var age: Int = 30
}

Example: Using @Observable with Property Observers

@Observable
class UserProfile {
    var name: String = "Jane"
    var email: String = "jane@example.com"

    var displayName: String {
        didSet {
            print("Display name changed to \(<span class="property">displayName</span>)")
        }
    }
}
Note: The @Observable macro automatically generates the necessary change tracking and observable property wrappers. You don't need to manually implement @Published or similar.
Warning: The @Observable macro is available starting in Swift 5.9 and requires Xcode 15.0 or later.

4. Using @Model Macro for SwiftData

Property Value Description
@Model struct User { ... } Declares a model type for SwiftData
@Attribute @Attribute var name: String Defines a property as an attribute in SwiftData
@Relationship @Relationship var posts: [Post] Defines a relationship to another model

Example: Define a SwiftData Model

@Model
final class User {
    var name: String
    var email: String
    
    init(name: String, email: String) {
        self.name = name
        self.email = email
    }
}

Example: Using Relationships

@Model
final class Post {
    var title: String
    @Relationship var author: User
    
    init(title: String, author: User) {
        self.title = title
        self.author = author
    }
}
Note: The @Model macro automatically synthesizes the required SwiftData storage methods and properties.
Warning: Ensure all properties marked with @Attribute are of supported types (String, Int, Double, Bool, Date, etc.)

5. Creating Freestanding Expression Macros

Freestanding expression macros in Swift allow you to define macros that can be used as expressions, not just statements. These macros are expanded at compile time and can transform Swift code.

Property Description Example
macro Defines a freestanding expression macro macro myMacro() = #externalMacro(module: "MyMacros", type: "MyMacro")
macro signature Defines the macro's input and output types macro myMacro<T>(value: T) -> T

Example: Basic Freestanding Expression Macro

import SwiftSyntax
import SwiftSyntaxBuilder

macro myMacro(value: Int) -> Int = #externalMacro(module: "MyMacros", type: "MyMacro")

// Usage in code:
let result = myMacro(value: 5)
Note: Freestanding expression macros must be defined in a separate module and imported into the target file.
Warning: Macro expansion happens at compile time, so any runtime errors in the macro will not be caught until compilation.

6. Creating Attached Member Macros

Attached member macros in Swift allow you to inject new members into types that conform to a protocol or extend a type. These macros are defined using the @attached(member) attribute.

Attribute Description Usage
@attached(member) Defines a macro that injects members into a type @attached(member) macro myMacro()

Example: Basic Attached Member Macro

@attached(member) macro logPropertyChanges() = #externalMacro(module: "MyMacros", type: "LogPropertyChangesMacro")

struct Person {
    @logPropertyChanges
    var name: String = "John"
}
Note: The macro implementation must be provided in a separate module, typically named MyMacros.

Key Points

  • Use @attached(member) to define macros that inject members into types
  • Macro implementations must be in a separate module
  • Can be used to add computed properties, methods, or other members

7. Understanding Macro Expansion

Macro expansion in Swift is the process by which macro definitions are transformed into actual code at compile time. This transformation happens before the compiler processes the code, allowing macros to generate or modify Swift source code.

Phase Description Result
Declaration Macro is declared using @macro or macro keyword Macro definition is registered in the compiler
Expansion Macro is invoked in code, compiler processes macro body Generated Swift code replaces the macro invocation
Compilation Generated code is compiled as normal Swift code Final executable or library is created

Example: Basic Macro Expansion

macro log(_ message: String) = #externalMacro(module: "LogMacros", type: "LogMacro")

// Usage in code:
log("Hello, World!")

// Expands to:
print("Hello, World!")
Note: Macro expansion happens at compile time, so there is no runtime cost for using macros. The generated code is fully type-checked and optimized by the Swift compiler.

Expansion Process Overview

  • Declaration: Macro is defined with a signature and implementation
  • Invocation: Macro is called in Swift code using its name and arguments
  • Compilation: Generated code is compiled as part of the normal Swift compilation process

8. Testing Macros

Example: Basic Macro Test

import XCTest
import SwiftSyntax
import SwiftSyntaxBuilder

final class MacroTests: XCTestCase {
    func testMacroExpansion() {
        let source = "let x = 42"
        let expanded = try! expandMacro(source, macroName: "myMacro")
        XCTAssertEqual(expanded, "let x = 42")
    }
}
Test Type Description Notes
Unit Test Test macro behavior in isolation Use XCTest framework
Integration Test Test macro in context of full compilation Requires swift build or Xcode build
Note: Macro tests require SwiftSyntax and SwiftSyntaxBuilder dependencies.
Warning: Macro expansion tests may not reflect runtime behavior in all cases.

Testing Strategy

  • Use XCTest for unit-level macro validation
  • Validate syntax tree transformations
  • Ensure macro behavior matches expected output

9. Debugging Macro Output

Example: Using dump in Macro Definitions

import SwiftSyntax
import SwiftSyntaxBuilder

struct MyMacro: ExpressionMacro {
  static func expansion(
    of node: some FreestandingMacroExpansionSyntax,
    in context: some MacroExpansionContext
  ) throws -> ExpressionSyntax {
    let dumpedValue = "Hello, World!"
    print(dumpedValue)
    return "\(dumpedValue)"
  }
}
Approach Description Notes
Print Debugging print() or dump() Useful for inspecting macro-generated code at compile time
Xcode Console Compile-time logs in Xcode console Output appears during compilation, not runtime
Note: Macro debugging is limited to compile-time output. Runtime behavior must be tested via generated code.

10. Following Macro Best Practices

Note: Swift macros are a powerful feature introduced in Swift 5.9, allowing developers to generate code at compile time. Following best practices ensures maintainability, readability, and performance.
Best Practice Description Implementation
Use descriptive names Macro names should clearly indicate their purpose. @available(iOS 17.0, *)
macro MyMacro() = #externalMacro(module: "MyMacros", type: "MyMacro")
Keep macros simple Avoid complex logic inside macros; keep them focused on code generation. macro simpleMacro() = #externalMacro(module: "MyMacros", type: "SimpleMacro")
Document macro usage Provide clear documentation for how to use the macro. Example: Documentation
/// This macro generates a simple print statement.
macro printMacro() = #externalMacro(module: "MyMacros", type: "PrintMacro")
Test macro behavior Ensure macros behave as expected with various inputs.
func testMacro() {
    #printMacro()
}
Avoid runtime dependencies Macros should not rely on runtime libraries or external dependencies. macro noRuntimeDep() = #externalMacro(module: "MyMacros", type: "NoRuntimeDep")

Summary of Macro Best Practices

  • Use descriptive names for clarity and maintainability.
  • Keep macro logic simple to prevent complexity and errors.
  • Document usage to help other developers understand the macro's purpose.
  • Test macros thoroughly to ensure correctness across different inputs.
  • Avoid runtime dependencies to ensure macro functionality at compile time.

Was this article helpful?