Swift Macros in 2025: A Practical Guide
Swift Macros in 2025: A Practical Guide
Updated for Swift 6.2 (released Sep 15, 2025). If you’re on Swift 5.9+ the fundamentals are the same; Swift 6.x added polish and tooling, not a new macro model. (Swift.org)
Table of contents
- 1. What are macros & why Swift added them
- 2. Macro kinds (freestanding vs. attached)
- 3. Defining & registering macros
- 4. Common use-cases
- 5. Step-by-step: build two macros
- 6. Debugging, testing & tooling
- 7. Limitations & best practices
- 8. Conclusion
- References & further reading
1. What are macros & why Swift added them
Macros generate Swift code at compile time. The compiler expands macro calls into real Swift that’s type-checked like anything you write by hand. Macro expansion is additive—it can insert code, not delete or mutate your existing source—so you can reason about the result and keep tooling stable. (Swift Documentation)
They sit next to language features like result builders or property wrappers: wrappers transform storage & access of one declaration; macros can generate new declarations, conformances, or expressions—reducing boilerplate while preserving clarity. (Swift Documentation)
2. Macro kinds (freestanding vs. attached)
Swift defines two families: (Swift Documentation)
- Freestanding macros appear by themselves and start with
#(e.g.#fileID,#warning). They produce an expression, statement, or declaration right at the call site. (Swift Documentation) - Attached macros are attributes (e.g.
@Something) applied to a declaration and can add members, accessors, peers, or extensions (with conformances). These are specified via roles in the macro declaration. (GitHub)
Syntax at a glance
// Freestanding
let path = #fileID // standard library macro
#warning("This compiles, but double-check input") // emits a diagnostic
// Attached (examples you might see in Apple frameworks or libraries)
@Observable
final class Model { /* ... */ } // expands into storage/observation plumbing
@SomeMacro
struct Foo { /* macro adds members or conformances */ }
Apple’s docs cover how freestanding calls insert code at the call site and how attached macros modify the annotated declaration. (Apple Developer)
3. Defining & registering macros
Authoring flow (SPM):
- Create a macro package:
swift package init --type macro
This scaffolds a macro implementation target, a library that exposes your macro declarations, and tests. (Swift Documentation)
- In the declarations target, you declare public macros and point them to their implementation with
#externalMacro(module:type:). (Apple Developer)
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "ExampleMacros", type: "StringifyMacro")
- In the implementation target you implement types that conform to macro protocols (e.g.
ExpressionMacro,MemberMacro,ExtensionMacro) and register them in a compiler plugin:
import SwiftCompilerPlugin
@main
struct ExamplePlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [StringifyMacro.self]
}
SPM exposes a dedicated .macro target kind and the CompilerPluginSupport helpers to link everything up. These targets are built for the host (e.g., macOS) and typically depend on SwiftSyntax/SwiftCompilerPlugin. (GitHub)
Tip: Your macro package often sets minimum platform versions for the macro target (host), e.g.,
.macOS(.v13), even if your library is for iOS. This resolves common “external macro type could not be found” build errors. (Stack Overflow)
4. Common use-cases
- Boilerplate removal & codegen Generate
CodingKeys,Equatable/Hashableimplementations,@Observablestorage,@AddCompletionHandlerpeers, or whole extensions via the extension macro role. (GitHub) - Compile-time validation Validate URLs, regexes, bundle resources, etc., and emit diagnostics at build time. (Example: a
#URL("…")macro that constructsURL(...)!after verifying the string.) (Vodafone tech) - Diagnostics & logging aids Macros can throw
MacroExpansionErrorMessage, emit warnings/errors, and attach Fix-Its to guide users. (Stack Overflow)
5. Step-by-step: build two macros
We’ll create a package with:
#stringify: a freestanding expression macro returning(value, "source code").@AddDescription: an extension macro that addsCustomStringConvertibleconformance and adescriptioncomputed property for a type’s stored properties.
Package layout
Package.swift (abbreviated):
// swift-tools-version: 6.2
import PackageDescription
import CompilerPluginSupport
let package = Package(
name: "MacroPlayground",
platforms: [.macOS(.v13), .iOS(.v17)], // host + client minimums
products: [
.library(name: "Macros", targets: ["Macros"]), // declarations client imports
.macro(name: "MacroImplementations", targets: ["MacroImplementations"])
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.1")
],
targets: [
.target(name: "Macros", dependencies: ["MacroImplementations"]),
.macro(
name: "MacroImplementations",
dependencies: [
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax")
]
),
.testTarget(
name: "MacroTests",
dependencies: [
"MacroImplementations",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
)
]
)
Notes:
.macrois the SPM target for compiler plugins; SwiftSyntax is the API you use to read/emit syntax trees during expansion. (GitHub)
5.1 #stringify (freestanding)
Declaration — Sources/Macros/Stringify.swift
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "MacroImplementations", type: "StringifyMacro")
Implementation — Sources/MacroImplementations/StringifyMacro.swift
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let expr = node.argumentList.first?.expression else {
context.diagnose(Diagnostic(node: Syntax(node),
message: SimpleError("`#stringify` needs exactly one expression")))
return "(\(literal: ""), \(literal: ""))"
}
// Build: ( <expr>, "<source>" )
return "(\(expr), \(literal: expr.description))"
}
}
// A tiny helper for diagnostics
struct SimpleError: Error, DiagnosticMessage {
let message: String
var diagnosticID: MessageID { .init(domain: "MacroPlayground", id: "stringify") }
var severity: DiagnosticSeverity { .error }
}
Use it — in any client module that imports Macros:
import Macros
let (value, source) = #stringify(2 + 3)
// value == 5, source == "2 + 3"
The #externalMacro hook names the module and type the compiler should invoke for expansion. (Apple Developer)
5.2 @AddDescription (attached extension macro)
Declaration — Sources/Macros/AddDescription.swift
@attached(
extension,
conformances: CustomStringConvertible,
names: named(description)
)
public macro AddDescription() =
#externalMacro(module: "MacroImplementations", type: "AddDescriptionMacro")
Implementation — Sources/MacroImplementations/AddDescriptionMacro.swift
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct AddDescriptionMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
// Support classes & structs
guard let decl = declaration.as(StructDeclSyntax.self) ??
declaration.as(ClassDeclSyntax.self) else {
context.diagnose(
Diagnostic(node: Syntax(declaration),
message: SimpleError("@AddDescription works on structs/classes only"))
)
return []
}
// Collect stored property identifiers
let names = decl.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { $0.bindings.allSatisfy { $0.accessorBlock == nil } } // stored only
.compactMap { $0.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text }
// "TypeName(a: \(a), b: \(b))"
let pairs = names.map { "\\(\($0)): \\(\(raw: $0))" }.joined(separator: ", ")
let typeName = type.trimmed
let ext: DeclSyntax = """
extension \(typeName): CustomStringConvertible {
public var description: String {
"\(typeName)(\(raw: pairs))"
}
}
"""
return [ext.cast(ExtensionDeclSyntax.self)!]
}
}
struct SimpleError: Error, DiagnosticMessage {
let message: String
var diagnosticID: MessageID { .init(domain: "MacroPlayground", id: "adddescription") }
var severity: DiagnosticSeverity { .error }
}
Use it — in client code:
import Macros
@AddDescription
struct User {
let id: Int
let name: String
}
print(User(id: 1, name: "Ryan"))
// → User(id: 1, name: Ryan)
Attached macro roles and the names:/conformances: requirements are specified in the Swift book and proposals; extension macros come from SE-0402. (Swift Documentation)
6. Debugging, testing & tooling
- See the expanded code in Xcode Place the caret on a macro use and choose Editor → Expand Macro to inline the generated code (undo to revert). Great for understanding and setting breakpoints in generated code. (Apple Developer)
- CLI & vision docs The macro vision notes a
-Xfrontend -dump-macro-expansions-style capability; availability can vary by toolchain. Prefer Xcode’s “Expand Macro” for stable flows. (Gist) - Unit test your macros Use
SwiftSyntaxMacrosTestSupport.assertMacroExpansionor community tools like pointfreeco/swift-macro-testing for snapshot-style assertions of expansion & diagnostics. (SwiftLee) - Diagnostics Emit structured diagnostics (
MacroExpansionErrorMessage, warnings, Fix-Its) from within expansion to guide users at compile time. (Stack Overflow) - Build trust prompts & CI Xcode requires trusting third-party macro packages (including on Xcode Cloud/CI). Pin platforms for the macro target and ensure the CI host OS matches your macro’s host minimum. (Stack Overflow)
7. Limitations & best practices
- Additive only: macros insert code; they don’t mutate existing user code in place. That keeps macro use auditable and tool-friendly. (Swift Documentation)
- No function-body rewriting in stable Swift: proposals for function body macros (SE-0415) were returned for revision—avoid designs that depend on rewriting existing bodies. Prefer generating peers/extensions. (Swift Forums)
- Declare what you generate: attached macro roles often require
names:to list generated symbols;extensionmacros must declare any conformances. This helps the compiler and IDEs stay accurate. (Swift Documentation) - Mind host vs. target: macro implementations build for the host platform (e.g., macOS). Keep your macro target’s platform settings consistent with your CI machines. (Swift Forums)
- Emit precise diagnostics: fail early with friendly messages; prefer compile-time validation over runtime traps. (Stack Overflow)
- Keep expansions small & predictable: readability wins. If a macro would output pages of code, consider a generator step or a library API instead.
8. Conclusion
Swift macros let you remove repetition, encode conventions, and shift many mistakes from runtime to compile time—all while staying within Swift’s type system and tooling. Use them to generate members, peers, and extensions; validate inputs early; and standardize boilerplate. But keep things predictable and auditable—don’t overuse macros when a plain API or property wrapper suffices. (Swift Documentation)
References & further reading
- The Swift Programming Language — Macros (official) (Swift Documentation)
- Apple Developer: Applying Macros (how to use + “Expand Macro”) (Apple Developer)
#externalMacro(module:type:)(Apple API reference) (Apple Developer)- SE-0382 Expression Macros (accepted) (GitHub)
- SE-0389 Attached Macros (accepted) (GitHub)
- SE-0402 Extension Macros (accepted) (GitHub)
- SwiftSyntax & macro protocols (foundation for macro impls) (GitHub)
- Testing macros: SwiftSyntax test support & Point-Free’s
swift-macro-testing(SwiftLee) - Compile-time URL validation example (blog) (Vodafone tech)
- Swift 6.2 release notes (current stable) (Swift.org)