Swift macros: concepts, trade-offs and production lessons
You're staring at your SwiftUI view and notice @Observable just works without you writing boilerplate. Then you see #Preview rendering your UI in Xcode without running the full app. Both of these are Swift macros—compile-time code generators that reduce repetition and surface errors earlier. Swift macros: concepts, trade-offs and production lessons will guide you through when to use them, how they work, and what we've learned from shipping them in production.
Macros are a powerful feature introduced in Swift 5.9 that let you generate code at compile time. Unlike runtime reflection or string-based metaprogramming, macros operate on your source code during the build phase, making them fast and type-safe. But they come with real costs: complexity, build time, and maintainability challenges that catch teams off guard.
@Observable and #Preview are macros
If you've used SwiftUI recently, you've already relied on macros without thinking about it. Let's look at two examples you encounter daily.
@Observable is an attached macro that transforms a class into one that notifies SwiftUI views of changes automatically. Before macros, you'd write this:
class CartModel: ObservableObject {
@Published var items: [Item] = []
@Published var total: Double = 0
}
With the macro, you write:
@Observable
final class CartModel {
var items: [Item] = []
var total: Double = 0
}
The macro generates the tracking infrastructure during compilation. You don't see it, but it's there—and it's identical every time. That's the first lesson: macros excel at eliminating boilerplate that follows a predictable pattern.
#Preview is a freestanding macro (we'll define this in a moment) that generates a preview provider without the ceremony of PreviewProvider. Instead of writing:
struct MyView_Previews: PreviewProvider {
static var previews: some View {
MyView()
}
}
You write:
#Preview {
MyView()
}
Both macros improve developer experience by hiding implementation details. But they also hide complexity—and when something goes wrong, that hidden complexity bites back.
Freestanding and attached macros
Swift defines two categories of macros, and understanding the difference shapes how you design and debug them.
Attached macros are applied with the @ syntax and work on declarations—types, functions, properties, etc. @Observable, @Model from SwiftData, and @Entry from AppIntents are all attached macros. They transform the thing they're attached to.
Freestanding macros are called like functions, prefixed with #, and can appear anywhere an expression can. #Preview, #warning, and #URL are examples. They don't attach to declarations; they generate code inline.
Let's build intuition with a production scenario. Imagine you're logging analytics events. You could write this freestanding macro:
#logEvent("purchase", ["productID": "SKU123", "price": 29.99])
The macro expands to generate structured logging with timestamps, user context, and validation—all at compile time. If you misuse it, the compiler catches the error before the app ships.
By contrast, an attached macro for a model might look like:
@CodableLogging
struct APIResponse {
let id: String
let timestamp: Date
let data: [String: Any]
}
This could generate Codable conformance plus automatic logging of decoding failures. The macro works on the struct itself, not on individual uses.
In production, we've found that freestanding macros are easier to reason about (they're explicit at the call site), while attached macros are riskier because their effects are invisible and applied globally to a type.
Expand macros in Xcode to see output
The most valuable debugging tool for macros is right in Xcode: the "Expand Macro" command. When you're confused about what code a macro is actually generating, right-click and select "Expand Macro" to see the generated Swift source.
This is critical for production work. We once had a team member spend hours debugging a memory leak, only to expand @Observable and realize the macro was retaining the entire model graph. Without expansion, that's invisible.
Here's a concrete workflow:
- Write code using a macro you're unfamiliar with.
- Build and run; confirm it works.
- Right-click on the macro usage.
- Select "Expand Macro" from the menu.
- Review the generated code—all of it.
- Ask: "Is this what I expected? Are there hidden costs?"
For example, expanding a complex @Model macro might reveal that SwiftData has injected initialization logic, property accessors, and SwiftUI observation hooks. Understanding this helps you avoid patterns that trigger expensive code paths, like initializing large models in computed properties.
Expanding macros also teaches you how Apple's own macros work. Study the expansion of @Observable, and you'll understand the performance characteristics of SwiftUI's observation system. This knowledge cascades into better architecture decisions.
Use existing macros before writing your own
This is the most important production lesson: writing a macro is expensive. It requires a separate macro target, careful AST manipulation, and thorough testing across edge cases. Most teams should not write macros.
First, audit the macros that ship with Swift and Apple frameworks:
@Observablefor reactive state.@Modelfor SwiftData persistence.@Entryfor AppIntents and Siri.#Previewfor UI preview declarations.#warningand#errorfor compile-time diagnostics.
Most production apps can solve 80% of their problems with these. The remaining 20% might benefit from custom macros, but the bar is high.
If you do consider a custom macro, ask yourself:
- Is this pattern truly repetitive across the codebase, and does it cause real bugs when done by hand?
- Can I solve this with a simpler tool—extensions, protocols with default implementations, or property wrappers?
- Am I confident I can document this macro clearly enough that new team members understand it without spending hours reading macro code?
- Does the build-time cost justify the reduction in runtime code?
We've seen teams write macros to generate networking code, model serialization, and dependency injection wiring. In most cases, these teams later replaced the macros with simpler abstractions or standard libraries. The cost of maintaining the macro—updating it for iOS versions, debugging macro-specific compiler errors, onboarding new engineers—often exceeds the benefit.
If you do write a macro, write it at the end of a project, not the beginning. Understand the pattern fully, ship it without a macro first, and only automate once you're certain the pattern is stable.
Common mistakes
Assuming the expanded code matches your expectations. Macros can generate code that looks right but behaves unexpectedly. Always expand and audit the output.
Using attached macros on types that have complex inheritance or protocol conformance. Macros often struggle with ambiguous cases. We've seen @Observable applied to a class with a custom init cause compilation errors that are hard to diagnose.
Writing custom macros without strong tests. Macros operate on the syntax tree, and edge cases (nested generics, unusual attribute combinations, compiler versions) break them in subtle ways. If you write a macro, invest heavily in test coverage.
Ignoring build time impact. Each macro invocation adds to compilation time. A codebase with dozens of complex macros can have noticeably slower builds. Profile before committing to heavy macro use.
Over-abstracting with macros when a simple function would suffice. Macros are impressive, but they're also a code smell if they're used to hide logic that could be expressed plainly. Prefer clarity over cleverness.
Key takeaways
- Swift macros generate code at compile time, reducing boilerplate and surfacing errors earlier than runtime approaches.
- Attached macros (like
@Observable) transform declarations; freestanding macros (like#Preview) are called inline. - Always expand macros in Xcode to see the generated code—this is your primary tool for understanding and debugging macro behavior.
- Use existing macros from Swift and Apple frameworks before writing your own; the maintenance cost of custom macros is often underestimated.
- If you write a custom macro, do so only after the pattern is proven and stable, and invest in comprehensive testing.
- Monitor build time impact; heavy macro use can slow compilation noticeably.
- Prefer clarity: if a macro hides logic that would be clearer as a regular function or protocol, don't use the macro.






Comments
Loading comments…