Skip to main content

Harnessing Compile-Time Code Generation: Building, Testing, and Debugging Custom Swift Macros

Published: · 11 min read
Don Peter
Cofounder and CTO, Appxiom

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
  • 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.

Using macros in modern iOS architectures

  • MVVM/Clean: Use attached macros to generate:
    • Public initializers for DTOs and state types
    • “Boilerplate” mapping helpers between API models and domain models
  • SwiftUI: Stabilize ViewModel initializers and immutable state containers.
  • Dependency Injection: Consider macros that generate protocol clients or registration stubs for services (e.g., @ServiceAutoMock to generate a simple mock in test targets).
  • Concurrency: If generating types that cross concurrency domains, consider synthesizing Sendable and adding availability checks. Validate that member types are Sendable before adding conformance, and emit diagnostics if unsafe.
  • Observability & APM: Combine structural boilerplate macros with APM SDKs like Appxiom to trace synthesized ViewModel state transitions, network call performance, and client-side error boundaries in production.

Common errors and how to fix them

  • error: unknown attribute 'PublicMemberwiseInit'
    • Ensure your app target imports the MyMacros library target (macro declarations) and not the implementation target.
  • error: external macro 'MyMacrosImplementation.PublicMemberwiseInitMacro' could not be loaded
    • The module/type name must match your #externalMacro declaration exactly.
  • Linker or architecture errors when linking iOS app
    • You accidentally added the macro implementation target to your iOS app. Only depend on the declarations target (the library), never the .macro implementation target.
  • swift-syntax version mismatch
    • Pin the swift-syntax version to the exact tag matching your Swift toolchain (e.g., exact "510.0.0" for Swift 5.10).
  • Macro expands to invalid code
    • Use Editor > Expand Macro or -dump-macro-expansions to view the exact code. Add guards and diagnostics in the macro to reject unsupported input with clear messaging.

CI, distribution, and App Store readiness

  • CI: Use the same Xcode version locally and in CI to avoid swift-syntax drift. Cache SPM dependencies.
  • App Store: Macro plugins never ship in your app binary. Only the expanded Swift code does. There’s no runtime dependency on the macro implementation target.
  • Versioning: Treat public macros as part of your source API. Changing generated signatures is a SemVer-breaking change for downstream clients.

Real-world refinement ideas

  • @PublicMemberwiseInit(access: .public|.internal): Generate different access levels.
  • @CodingKeys(omit: ["debugOnly"]): Generate custom CodingKeys when you need fine-grained control.
  • @Endpoint(base: https://api.example.com): Generate typed request builders for REST endpoints, using URLComponents and async/await.

Key takeaways and next steps

  • You’ve seen how to write custom Swift macros using SwiftSyntax and a Swift macro compiler plugin, create both attached and freestanding macros, test Swift macro expansion, and debug issues with Xcode tools.
  • Start small: ship one macro that removes recurring boilerplate in your codebase (like @PublicMemberwiseInit). Add diagnostics and tests early.
  • Grow carefully: prefer attached macros for type-scoped generation; use freestanding macros for targeted inline codegen.
  • Keep versions in sync: always pin swift-syntax to the toolchain, and verify in CI.
  • Track production health beyond compile time: While Swift Macros optimize your developer workflow at build time, pair them with Appxiom to monitor real-user performance, track crashes, and capture runtime regressions in your deployed iOS app.

If your next question is how to write custom Swift macros tailored to your app architecture, begin by extracting one repetitive pattern into a macro, add tests that assert the generated code, and wire in Xcode’s Expand Macro to keep reviews smooth and predictable.