Harnessing Compile-Time Code Generation: Building, Testing, and Debugging Custom Swift Macros
Swift Macros let you generate Swift code at compile time - safely, deterministically, and with IDE support. If you’ve ever copy/pasted boilerplate, wished for a “public memberwise init” on your library types, or wanted to generate conformance based on a few rules, macros are the right tool. This post is a practical, end-to-end guide to how to write custom Swift macros, including setup, implementation, how to test Swift macro expansion, and how to debug them in Xcode. We’ll also cover freestanding vs attached macro Swift use cases, performance, and production considerations for iOS apps.
Prereqs and versions
- Xcode: 15.4+ (Swift 5.10) or Xcode 16+ (Swift 6 mode)
- Platforms: Macros run in a host process at build time (macOS). The generated code works in your iOS app target with no runtime plugin.
- Packages: Use the swift-syntax package version that matches your toolchain:
- Swift 5.9 → 509.x
- Swift 5.10 → 510.x
- Swift 6 (Xcode 16) → 600.x Check the swift-syntax README for the exact tag to pin.
What we’ll build
- An attached member macro @PublicMemberwiseInit that synthesizes a public memberwise initializer for structs/classes. This is a common library need: you want a stable public initializer without hand-writing it.
- A freestanding expression macro #log(...) for lightweight logging (production note: wire to os.Logger if desired).
We’ll wire these up via a Swift macro compiler plugin using SwiftSyntax and SwiftSyntaxBuilder, write unit tests that assert expansions, and walk through real debugging workflows.
Why macros for iOS developers?
- Eliminate boilerplate that clutters your Feature modules (e.g., “DTOs with initializers,” “ViewModels with scaffolding helpers”).
- Make your public API surfaces explicit and consistent across modules.
- Keep generated code visible and reviewable in Xcode (“Expand Macro”), unlike source generation scripts that run out-of-band.
- No runtime overhead or reflection: expansions become regular Swift code compiled into your app.
Project layout: a minimal, production-friendly structure
Use a Swift Package to host your macros. Your iOS app will depend on the package target that exposes macro declarations - but never link the macro implementation target into the iOS app.
Package.swift (outline)
- MyMacrosImplementation (target type: .macro)
- Depends on SwiftSyntax, SwiftSyntaxBuilder, SwiftCompilerPlugin
- Contains the macro implementations
- MyMacros (target type: .library)
- Declares the macros using #externalMacro and re-exports them to clients
- MyMacrosTests (target type: .test)
- Uses SwiftSyntaxMacrosTestSupport to assert expansions
Example Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MyMacros",
platforms: [.iOS(.v15), .macOS(.v13)],
products: [
.library(name: "MyMacros", targets: ["MyMacros"])
],
dependencies: [
// Pin to the tag matching your toolchain
.package(url: "https://github.com/apple/swift-syntax.git", exact: "510.0.0")
],
targets: [
.macro(
name: "MyMacrosImplementation",
dependencies: [
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax")
]
),
.target(
name: "MyMacros",
dependencies: ["MyMacrosImplementation"]
),
.testTarget(
name: "MyMacrosTests",
dependencies: [
"MyMacrosImplementation",
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
)
]
)
Note: Keeping the macro implementation target decoupled ensures that your main app binary - and runtime observability tools like Appxiom - only bundle production-ready code without unused compiler dependencies.
Implementing an attached macro: @PublicMemberwiseInit
Goal: For any struct/class with stored properties, synthesize a public memberwise initializer using defaults where provided.
Why this is useful
- Swift’s memberwise initializer is internal for structs and not synthesized for classes. Public libraries often need a stable, public initializer but don’t want to hand-maintain it.
- The macro keeps the initializer co-located and always in sync.
Macro declaration (in MyMacros target)
// Sources/MyMacros/PublicMemberwiseInit.swift
@attached(member, names: named(init))
public macro PublicMemberwiseInit() =
#externalMacro(module: "MyMacrosImplementation", type: "PublicMemberwiseInitMacro")
Macro implementation (in MyMacrosImplementation target)
// Sources/MyMacrosImplementation/PublicMemberwiseInitMacro.swift
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct PublicMemberwiseInitMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf decl: DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Support structs and classes
guard decl.is(StructDeclSyntax.self) || decl.is(ClassDeclSyntax.self) else {
return []
}
// Collect stored properties (skip static/computed)
struct StoredProp {
let name: String
let type: TypeSyntax
let defaultValue: ExprSyntax?
}
var props: [StoredProp] = []
for member in decl.memberBlock.members {
guard
let varDecl = member.decl.as(VariableDeclSyntax.self),
varDecl.bindings.count == 1,
let binding = varDecl.bindings.first,
binding.accessorBlock == nil, // computed if accessor present
let ident = binding.pattern.as(IdentifierPatternSyntax.self),
let type = binding.typeAnnotation?.type
else { continue }
if varDecl.modifiers?.contains(where: { $0.name.tokenKind == .keyword(.static) }) == true {
continue
}
props.append(.init(
name: ident.identifier.text,
type: type,
defaultValue: binding.initializer?.value
))
}
guard !props.isEmpty else { return [] }
// Build parameter list with defaults when present
let params = props.map { p in
if let dv = p.defaultValue {
return "\(p.name): \(p.type) = \(dv)"
} else {
return "\(p.name): \(p.type)"
}
}.joined(separator: ", ")
let assigns = props.map { "self.\($0.name) = \($0.name)" }
.joined(separator: "\n ")
let initDecl: DeclSyntax = """
public init(\(raw: params)) {
\(raw: assigns)
}
"""
return [initDecl]
}
}
@main
struct MyMacroPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
PublicMemberwiseInitMacro.self,
LogMacro.self
]
}
Use in your app module
import MyMacros
@PublicMemberwiseInit
public struct FeatureConfig {
public var endpoint: URL
public var retries: Int = 3
var apiKey: String // internal, still included as a parameter
}
// Expanded:
// public init(endpoint: URL, retries: Int = 3, apiKey: String) { ... }
Notes and trade-offs
- Access control: The init is public. You can add macro parameters later (e.g., @PublicMemberwiseInit(access: .internal)) to customize.
- Classes: Works the same; you get a designated initializer with the same assignments.
- Stored-only: Computed properties, lazy with accessors, and statics are skipped.
- Defaults: Preserves default values from declarations.
Implementing a freestanding macro: #log
Freestanding macros are invoked like functions but expand at compile time into expressions, declarations, etc.
Macro declaration (in MyMacros target)
// Sources/MyMacros/Log.swift
@freestanding(expression)
public macro log(_ message: Any) -> Void =
#externalMacro(module: "MyMacrosImplementation", type: "LogMacro")
Macro implementation (in MyMacrosImplementation target)
// Sources/MyMacrosImplementation/LogMacro.swift
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct LogMacro: ExpressionMacro {
public static func expansion(
of node: MacroExpansionExprSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// Expect one argument; fallback to a literal if missing
let messageExpr = node.argumentList.first?.expression
?? ExprSyntax(stringLiteral: "\"<no message>\"")
// Simple and portable: print. You can switch to os.Logger if your clients import os.
let expr: ExprSyntax = "print(String(describing: \(messageExpr)))"
return expr
}
}
Usage
import MyMacros
func didTapPrimary() {
#log("User tapped primary")
// expands to: print(String(describing: "User tapped primary"))
}
Production tip: If your codebase standardizes on os.Logger, expand to that API and document that importing os in the using module is required.
Freestanding vs attached macro Swift: when to use which
- Freestanding macros (#something):
- Behave like calls; can expand to expressions, declarations, or code blocks.
- Good for logging helpers, compile-time assertions, DSL sugar, one-off codegen.
- Attached macros (@Something):
- Attach to a declaration and can add members, conformances, peers, or extensions.
- Good for synthesizing initializers, Codable helpers, protocol conformances, and API uniformity across many types.
Pick attached macros when your codegen should “follow the type,” and freestanding when you need one-off expansion in-line with statements/expressions.
swiftsyntax tutorial macro development: practical guidance
- Keep the macro implementation target small and focused. It’s a separate tool that runs inside the compiler. Avoid heavy dependencies.
- Parse syntax precisely:
- Only treat bindings without accessorBlock as stored properties.
- Skip statics.
- Consider attributes like @available or @MainActor if you generate conformances or apply attributes.
- Prefer SwiftSyntaxBuilder or DeclSyntax(stringLiteral:) for readability when generating code.
- Validate inputs and surface useful diagnostics instead of silently doing nothing.
Testing: how to test Swift macro expansion
Use SwiftSyntaxMacrosTestSupport to assert the expanded source. These tests run fast and catch regressions in your codegen.
// Tests/MyMacrosTests/PublicMemberwiseInitMacroTests.swift
import XCTest
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
@testable import MyMacrosImplementation
final class PublicMemberwiseInitMacroTests: XCTestCase {
func testGeneratesPublicInit() {
assertMacroExpansion(
"""
@PublicMemberwiseInit
public struct User {
public var name: String
var age: Int = 0
}
""",
expandedSource:
"""
public struct User {
public var name: String
var age: Int = 0
public init(name: String, age: Int = 0) {
self.name = name
self.age = age
}
}
""",
macros: ["PublicMemberwiseInit": PublicMemberwiseInitMacro.self]
)
}
}
You can also test diagnostics:
- Provide invalid input.
- Ensure your macro emits a diagnostic with the correct severity and fix-its (via SwiftDiagnostics).
Debugging: seeing and verifying expansions
- Xcode “Expand Macro”: Place the caret on a macro usage, then Editor > Expand Macro (or contextual menu). Xcode shows the generated code inline.
- Dump macro expansions in build logs:
- Project > Build Settings > Swift Compiler – Custom Flags > Other Swift Flags:
- Add: -Xfrontend -dump-macro-expansions
- Project > Build Settings > Swift Compiler – Custom Flags > Other Swift Flags:
- Add targeted prints in the macro implementation (they appear in the build log). Use sparingly.
- Emit diagnostics from macros:
- Import SwiftDiagnostics and call context.diagnose(...) with a custom message to guide users.
Example diagnostic
// inside expansion(...)
import SwiftDiagnostics
struct MissingTypeAnnotation: DiagnosticMessage {
let message: String = "Property must have an explicit type annotation"
let diagnosticID = MessageID(domain: "MyMacros", id: "missingType")
let severity: DiagnosticSeverity = .error
}
// if a property lacks a type annotation you require:
context.diagnose(Diagnostic(node: Syntax(decl), message: MissingTypeAnnotation()))
Performance, stability, and build-time hygiene
- Make expansions simple and deterministic. Avoid recursion or generating huge declarations.
- Don’t do I/O or networking from macros. The compiler plugin process is sandboxed for safety and stability.
- Fail fast with clear diagnostics. Silent failures frustrate users and slow teams down.
- Cache nothing global. Treat each expansion as independent; rely on the syntax provided by the compiler.
- Prefer attached macros over source - file transforms - they keep generated code colocated and scoped.
