- Published on
- 7 min read Advanced
> Understanding Swift Macros: A Practical Guide
// 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:
// #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:
@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:
// @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:
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:
@freestanding(expression)
public macro URL(_ string: String) -> URL =
#externalMacro(module: "URLMacrosMacros", type: "URLMacro")
The implementation uses SwiftSyntax to inspect and generate code:
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:
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct URLMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
URLMacro.self,
]
}
Now you can use it:
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:
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
// Continue_Learning
Getting Started with Swift Testing: Apple's Modern Test Framework
Swift Testing replaces XCTest with a cleaner macro-based API. Learn how to write tests with @Test, #expect, parameterized inputs, and traits in Xcode 16+.
How to Free Up Disk Space Used by Xcode
Xcode can consume 50GB+ of disk space over time. Here's how to reclaim storage by clearing caches, old simulators, device support files, and other accumulated cruft.
Error Handling in Swift: From Basics to Typed Throws
A practical guide to Swift error handling covering throw/try/catch fundamentals, custom error types, Result, async error patterns, and typed throws introduced in Swift 6.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.