#Swift Macros: Explained
I spent a year avoiding Swift macros, then noticed I was leaning on them every single day. This is the tour I wish someone had handed me back then.
#Part 1: The Mental Model
Macros sat in our “later” pile for a full year after Swift 5.9 landed, mostly because the name alone was off-putting: SwiftSyntax, AST nodes, and dylib plugins that build as separate compiler targets. It read like a weekend we were happy to let somebody else have.
Then it dawned on us that we’d been living inside them the whole time without noticing. There was @Observable on every view model, #Preview under every view, #expect scattered through the tests. We used them constantly and never once looked under the hood.
The surprise, when we finally did write one, wasn’t the code generation at all. It was the error messages. Our own macro could paint a red squiggle under a teammate’s mistake, land it on the precise token, and attach a “Fix” button that actually fixed the thing, and all of that came out of maybe forty lines of our own code.
So that’s the real pitch, and “less boilerplate” badly undersells it. What a macro gives us is compile-time feedback that we get to write ourselves.
Five parts ahead. First, the mental model: what a macro is, and the two shapes it takes. Second, the ones worth adopting today, with nothing to write and only switches to flip. Third, rolling our own from start to finish, with something we’d actually ship. Fourth, diagnostics and fix-its, which most tutorials wave at and which turn out to be the whole game. Fifth, the ecosystem, meaning which third-party macros are worth their build cost and which tools make authoring bearable.
Here’s the entire idea. A macro is code that runs while the compiler works. It reads our source as input and hands back more source as output. Nothing survives to runtime. No reflection, no string lookups, no cost once the app is built.
If you’ve been burned by C’s #define, breathe. Swift's version is a different animal, for two reasons.
It only adds. A macro can generate new code. It can never quietly rewrite or delete what we typed. Declare a property and no macro on earth can make it disappear. That single guarantee is why pulling a macro from some random package doesn’t require reading its guts line by line first.
And it gets checked coming and going. The compiler type-checks what flows in, then type-checks the generated code on the way out. Expand into nonsense and you get a build error at your desk, not a mystery crash at 2 a.m.
#Two shapes
Freestanding macros wear a # and stand alone as an expression or statement:
func connect() {
print("Entering \(#function)")// #function is literally a macro
let url = #URL("https://api.atlys.com/v2/health")// custom, validated at compile time
}
Small thing that reframes everything. #file, #line, and #function, the ones we've typed for years, got rebuilt as macros in 5.9. So we were macro users long before we noticed.
Attached macros wear an @ and clip onto a declaration to extend it:
@Observable
final class CartViewModel {
var items: [CartItem] = []
var isCheckingOut = false
}
@Observable rewrites that class behind the curtain. Observation registrars, wrapped property access, the works, and none of it shows up unless we right-click and pick Expand Macro in Xcode. Do it once. Really. Ten seconds of watching the expansion unfold does more than this whole section.
Attached macros play roles, and one macro can wear several hats. @attached(member) bolts members onto a type. @attached(extension) adds conformances. @attached(peer) drops a sibling declaration next to it. @attached(accessor) supplies getters and setters. @Observable juggles three at once.
#What actually happens at expansion
The compiler is stingy about what it shares. It doesn’t ship the macro your whole file. Instead it snips out just the syntax tree of where the macro was used, ships that to a separate sandboxed process (our macro, compiled as a plugin), takes the generated source back, splices it in, and type-checks the lot.
The sandbox is the interesting part. A macro can’t read files, can’t phone home, can’t touch anything but the syntax we handed it. Same input in, same output out, every single time. It’s also the tax. Every macro package is a plugin that has to build first, and SwiftSyntax is a heavy dependency to drag along. Stack five macro packages onto a big app and you’ll feel it on a clean build. Worth knowing before the checkout spree.
#Part 2: The Macros That Help Us Today: -
#@DebugDescription, the one you probably came for
Shipped in Swift 6 and Xcode 16 (SE-0440), and it scratches an itch every iOS dev has felt. Your type conforms to CustomDebugStringConvertible, you did everything right, and Xcode's variable inspector still shows a collapsed row that tells you nothing. Crash logs? Emptier still.
Root cause: debugDescription runs at runtime. To read it, LLDB has to compile and execute an expression inside your process. That's literally what po does, which is why po drags. The variables pane can't be bothered to pay that cost, so it doesn't.
@DebugDescription sidesteps all of it. At compile time it converts your debugDescription into an LLDB type summary and bakes it into the binary:
@DebugDescription
struct VisaApplication: CustomDebugStringConvertible {
let country: String
let travellerCount: Int
let status: Status
var debugDescription: String {
"\(country), \(travellerCount) traveller(s), \(status)"
}
}
Now "IN, 2 traveller(s), processing" just appears: right in the variable view with no expanding and no po, under the fast p command with no expression evaluation, and even in spots where running code isn't an option at all.
There’s a catch, and it’s the reason the whole thing works. debugDescription has to stay simple. String interpolation over stored properties, more or less. No method calls, no computed values, no if branching. Write something too clever and the macro refuses at compile time, and how it refuses is the good stuff we save for Part 4 (spoiler: context.diagnose). Need a rich runtime string and a debugger summary both? Keep the fancy debugDescription and add a plain lldbDescription alongside it. The macro looks for that name too.
Where it pays off: the objects you inspect on repeat. Network response wrappers. State enums carrying associated values. Anything you’d otherwise po twenty times before lunch.
##expect and #require, why Swift Testing feels sharper than XCTest
XCTest shipped a whole menagerie of assertions (XCTAssertEqual, XCTAssertNil, XCTAssertGreaterThan) for one sad reason. A bare XCTAssert(a == b) had nothing to report except "the Bool was false." Swift Testing collapses the menagerie into one:
@Test func discountApplies() {
let cart = Cart(items: [.visa(fee: 4500)], coupon: "FIRST10")
#expect(cart.total == 4050)
}
Fail it and read this:
Expectation failed: (cart.total is 4500) == 4050
It prints the actual value of each piece of the expression. A plain function handed a Bool never could, because by the time it runs the operands are gone. #expect is a macro, so at compile time it holds the syntax tree of cart.total == 4050 and can wire up capture on every operand. @Test and @Suite are attached macros too, quietly doing the test discovery that XCTest used to pull off with Objective-C runtime hacks.
##Preview, same trick, smaller stakes
#Preview("Dark mode") {
TicketCard(ticket: .sample)
.preferredColorScheme(.dark)
}
Goodbye, PreviewProvider struct ceremony. It's a freestanding declaration macro that emits the registration boilerplate for you. And people forget this constantly: it works for UIKit view controllers too.
#@Observable, the one that rewired SwiftUI
Old ObservableObject was blunt. Change any @Published property and every view watching the object redrew. @Observable is precise. It notices exactly which properties a given view reads, and re-renders only when those move. On a busy list screen the gap is not subtle. Migrating is mostly a delete key, since @Published and @StateObject go away.
#A few more worth a nod
#warning("...") and #error("...") aren't technically macros; they're compiler directives. Same mental shelf, though: messages you fire at developers at build time. #warning("remove before release") is the honest man's TODO.
@Model (SwiftData) turns a plain class into a persisted one. The entire persistence layer underneath is macro-generated.
@Generable (FoundationModels, iOS 26) hands on-device LLMs a schema so they can hand you back instances of your own types. Anyone who's poked at Apple's Foundation Models has seen how much ceremony it quietly eats.
#Part 3: Writing Our Own Macro
Let’s build something tiny that pulls real weight: #URL. A freestanding macro that checks a URL string at compile time and gives back a non-optional URL. No more URL(string: "...")! sprinkled everywhere, and no more finding a fat-fingered scheme the hard way, in production.
#Setup
In Xcode: File, then New, then Package, then Swift Macro. Four targets pop out. MyMacros holds the public declarations clients import. MyMacrosMacros is the actual implementation, i.e. the compiler plugin. MyMacrosClient is a scratch executable to play in. And there's a tests target.
#1. Declare it
// MyMacros/URL.swift
import Foundation
@freestanding(expression)
public macro URL(_ string: StaticString) -> URL =
#externalMacro(module: "MyMacrosMacros", type: "URLMacro")
Read that as a contract: give it a static string, get a URL back. #externalMacro is the pointer to the implementation type. And StaticString is doing real work here. Demanding a literal is the only reason compile-time validation is honest. You can't vet a runtime variable that doesn't exist yet.
#2. Implement it
// MyMacrosMacros/URLMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros
import Foundation
public struct URLMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// Pull the string literal out of the syntax tree
guard
let argument = node.arguments.first?.expression,
let literal = argument.as(StringLiteralExprSyntax.self),
literal.segments.count == 1,
case .stringSegment(let segment) = literal.segments.first
else {
throw MacroError.requiresStaticStringLiteral
}
let urlString = segment.content.text
// The actual validation
guard let url = URL(string: urlString), url.scheme != nil else {
throw MacroError.invalidURL(urlString)
}
return "URL(string: \(literal))!"
}
}
enum MacroError: Error, CustomStringConvertible {
case requiresStaticStringLiteral
case invalidURL(String)
var description: String {
switch self {
case .requiresStaticStringLiteral:
"#URL requires a static string literal"
case .invalidURL(let s):
"\"\(s)\" is not a valid URL"
}
}
}
Yes, there’s a force unwrap in the generated code. It’s fine. We already proved the string parses before we emitted a single character. That’s the trick in one sentence: drag the risk from runtime up to compile time, and the ! stops being a gamble and starts being a proof.
#3. Register the plugin
@main
struct MyMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [URLMacro.self]
}
#4. Use it
let health = #URL("https://api.atlys.com/v2/health")// valid, URL, non-optional
let broken = #URL("htp:/oops")// compile error: "htp:/oops" is not a valid URL
That typo can no longer ship. Forty-odd lines, and it earns them.
#Now an attached one
Sketch, not full listing. @CaseDetection, a member macro that spits out isX booleans for enum cases. The exact chore we hand-write for every state enum:
@CaseDetection
enum LoadState {
case idle, loading, loaded(Payload), failed(Error)
}
// generates:
// var isIdle: Bool { if case .idle = self { true } else { false } }
// var isLoading: Bool { ... }
// etc.
if state.isLoading { showSpinner() }
It conforms to MemberMacro, walks EnumDeclSyntax.memberBlock looking for EnumCaseDeclSyntax nodes, and emits one computed property per case it finds. Structurally it's URLMacro again, just chewing on a declaration instead of an expression. I'm skipping the full code on purpose, because the happy path was never the interesting bit. The interesting bit is what happens when someone slaps @CaseDetection on a struct. Which is exactly where we're headed.
#Part 4: Diagnostics, Making Our Macro Talk Back
This is what people are pointing at when they say “diagnose” in macro-land. Throwing an error from expansion(), like we did up in Part 3, works, but it's a hammer. The error smears across the whole macro usage, no severity to speak of, no fix-it. The real instrument is context.diagnose(), out of the SwiftDiagnostics module. It's the difference between a macro your team tolerates and one they curse.
Here’s what it buys us. A message pinned to one specific syntax node, meaning the exact wrong token and not the whole line. A severity dial of .error, .warning, or .note. And a fix-it, which Xcode draws as a clickable "Fix" button.
#Anatomy of a diagnostic
Start with a message type:
import SwiftDiagnostics
struct CaseDetectionDiagnostic: DiagnosticMessage {
let message: String
let severity: DiagnosticSeverity
var diagnosticID: MessageID {
MessageID(domain: "MyMacros", id: "CaseDetection")
}
static let notAnEnum = CaseDetectionDiagnostic(
message: "@CaseDetection can only be applied to an enum",
severity: .error
)
}
Then, in the macro itself, we swap the throw for a diagnose:
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard declaration.is(EnumDeclSyntax.self) else {
context.diagnose(Diagnostic(
node: node, // points at "@CaseDetection" itself
message: CaseDetectionDiagnostic.notAnEnum
))
return [] // emit nothing, but we've said why
}
// ... generate the properties
}
Misuse it now and a tidy red underline lands right on the attribute. Not “macro expansion failed.” The phrasing reads like the Swift compiler wrote it, which, in effect, it did.
#Fix-its, the part that delights
Suppose the macro insists the enum be public. Don't just scold. Offer the repair:
let fixIt = FixIt(
message: AddPublicFixIt(), // a FixItMessage: "add 'public' modifier"
changes: [
.replace(
oldNode: Syntax(enumDecl),
newNode: Syntax(enumDecl.with(\.modifiers, publicModifiers))
)
]
)
context.diagnose(Diagnostic(
node: enumDecl.enumKeyword,
message: CaseDetectionDiagnostic.needsPublic,
fixIt: fixIt
))
Developer sees the error, clicks Fix, code corrects itself. Same plumbing @DebugDescription reaches for when it turns down a too-clever debugDescription, and the same plumbing @Observable uses to grumble about property wrappers it can't work with. The standard library leans on this hard. The @DebugDescription implementation ships its own little context.diagnose(node:error:) helper extension.
One more thing, on warnings. Not every problem deserves to halt the build. Spot something legal but fishy, say @CaseDetection on a single-case enum, and .warning lets you flag it and move on. Save .error for "this expansion would be flat wrong." Reach for .warning when you mean "we probably didn't mean this."
#Testing our diagnostics
A diagnostic is a behavior, so test it like one. assertMacroExpansion from SwiftSyntax takes a diagnostics: argument for exactly this:
func testRejectsStruct() {
assertMacroExpansion(
"""
@CaseDetection
struct NotAnEnum {}
""",
expandedSource: """
struct NotAnEnum {}
""",
diagnostics: [
DiagnosticSpec(
message: "@CaseDetection can only be applied to an enum",
line: 1, column: 1
)
],
macros: ["CaseDetection": CaseDetectionMacro.self]
)
}
Point-Free’s swift-macro-testing takes this further. It snapshots expansions, draws diagnostics inline with little tree arrows in the output, and even records how a fix-it rewrites the source. Serious about macros? Use it.
#Part 5: The Community
Things moved fast after 5.9. The go-to index is krzysztofzablocki/Swift-Macros, an awesome-list kept by the author of Sourcery, which tracks, since macros swallowed most of what Sourcery used to do. Sixty-odd packages sit on it. We don’t need sixty. Here’s the honest short version, sorted by the pain each one kills.
#Mocks: Mockable and Spyable
Hand-writing mocks? This is the biggest single win on the whole list. Mockable grows a full mock off a protocol annotation:
@Mockable
protocol VisaService {
func fetchSteps(for country: String) async throws -> [AddOnStep]
}
// in tests:
let service = MockVisaService()
given(service).fetchSteps(for: .any).willReturn([.sample])
verify(service).fetchSteps(for: .value("MV")).called(1)
Spyable is the lighter cousin. It generates a spy that just records calls and lets you stub returns through plain properties, with no DSL to learn. If Mockable's given/when/verify gives you Mockito flashbacks you'd rather not have, start on Spyable.
Tangent worth the detour: swift-power-assert. Same expression-capturing move as #expect, except it draws a full tree of every subexpression's value under the failing line. Mostly a gift for anyone still married to XCTest.
#Codable: MetaCodable
Every codebase has the same graveyard, meaning hand-rolled CodingKeys and init(from:) that exist for one dumb reason each. One field is snake_case. One value needs a fallback. MetaCodable buries almost all of it:
@Codable
struct Traveller {
@CodedAt("passport_no")
var passportNumber: String
@Default("IN")
var citizenship: String // decode failure or null falls back to "IN" instead of throwing
}
@Default is the star. Fall back instead of tanking the entire payload. One bad optional in a list response no longer takes the whole decode down with it. Debug a single nil field that nuked a 200-item API response even once and you'll get why this earns a slot.
#Enums: CasePaths
Point-Free’s @CasePathable gives enum cases the thing key paths gave properties, meaning real composable references you can pass around:
@CasePathable
enum Destination {
case detail(TicketID)
case checkout(Cart)
}
// dynamic member lookup on cases:
if let ticketID = destination.detail { ... }
It’s load-bearing inside the Composable Architecture, but you can lift it out and use it anywhere you branch on enums. It also quietly does everything our Part 3 @CaseDetection did, which is the lesson, really. Before writing a macro, go check whether Point-Free already wrote it.
#Networking: Papyrus
Papyrus is basically Retrofit for Swift. Your API becomes an annotated protocol:
@API
protocol AtlysAPI {
@GET("/api/doxie/v2/get_add_on_steps")
func addOnSteps(@Query ticketID: String) async throws -> [AddOnStep]
}
The macro writes the whole client, and since it’s protocol-first you get a mockable seam thrown in. Does it beat the Moya setup you already have? Judgment call. But on a greenfield module it deletes an entire stratum of by-hand request building.
#Small, sharp, single-purpose Macros
SFSymbolsMacro verifies SF Symbol names at compile time. Image(systemName: "checkmark.circel") simply won't build. Same religion as our #URL, converting a stringly-typed runtime failure into a red squiggle.
MemberwiseInit gives you a public memberwise init that respects what you meant. Maintain a modularized codebase and you've written this init a hundred times, because SPM module walls make Swift's synthesized one worthless. It's always internal.
ModifiedCopyMacro and SwiftCopyable port Kotlin's data class copy() to structs. State reducers love these.
#The tools (secretly the best part of that repo)
Swift AST Explorer lets you paste Swift and watch the SwiftSyntax tree light up live. Writing URLMacro back in Part 3, the fact that a string literal is a StringLiteralExprSyntax wrapping segments? You learn that here in thirty seconds, not by flailing at the compiler. Keep it pinned in a tab the entire time you author a macro.
swift-macro-testing is the Part 4 one. It snapshot-records expansions and matches diagnostics by string, not by line and column, so your tests quit shattering every time you reformat a file.
Macro ToolKit is a kinder layer over raw SwiftSyntax for the patterns you’ll hit over and over.
#One word: restraint
Every package up there is a SwiftSyntax-backed compiler plugin, and the bill adds up. Clean-build time on one side, and on the other, a pile of invisible generated code your team now has to hold in its head. Our rule of thumb: adopt a community macro when it replaces something we already write on repeat, like mocks, CodingKeys, inits. Skip the ones that hand us patterns we didn’t have before. An awesome-list is a menu. It is not a shopping list.
And a rough decision tree for the bigger question, macro or not.
Adopt without a second thought: @DebugDescription, #Preview, #expect, @Observable. Apple maintains them, the build cost is already sunk, the payoff is same-day.
Adopt on purpose: the community ones that erase boilerplate you genuinely repeat, like Mockable or Spyable, MetaCodable, CasePaths. A package or two you chose, not ten you collected.
Write one when the boilerplate is truly repeated across many call sites, mechanical enough that generation can’t botch it, and, the deciding factor, checkable. That compile-time validation angle, the #URL move, is where macros leave snippets and templates in the dust.
Don’t write one when a protocol extension, a generic, or a property wrapper already covers it. Macros cost build time, a fat dependency, and a debugging story one step removed from normal code. A macro that saves three lines isn’t clever. It’s a liability wearing a #.
Whatever you end up building, pour as much care into the diagnostics as the expansion. The expansion is what the macro does. The diagnostics are what it feels like to use.
If you take exactly one action from all of this, make it this. Open any file with @Observable, right-click, hit Expand Macro. It's a better SwiftSyntax tutorial than most SwiftSyntax tutorials.
#References
Official documentation: Macros, The Swift Programming Language, SE-0382: Expression Macros, SE-0389: Attached Macros, SE-0440: DebugDescription Macro, @DebugDescription API docs, swift-syntax, Apple’s example macros
WWDC23 sessions: Write Swift macros, Expand on Swift macros
Tools: Swift AST Explorer, swift-macro-testing, Macro ToolKit
Community packages: Awesome Swift Macros (the full curated list), Mockable, Spyable, swift-power-assert, MetaCodable, CasePaths, Papyrus, SFSymbolsMacro, MemberwiseInit, ModifiedCopyMacro
Swift Macros, Explained Like We Actually Ship Apps was originally published in Atlys Engineering on Medium, where people are continuing the conversation by highlighting and responding to this story.
