Swift & Objective-C

[Swift Deep Dive #7] Swift Macros Explained: The Truth Behind @Observable

Swift 5.9 macros are code-generation plugins embedded in the compiler. This overview covers how they receive syntax trees and return code, the freestanding (#) and attached (@) branches, and the cost of creating your own.

6 min read
Cover image for [Swift Deep Dive #7] Swift Macros Explained: The Truth Behind @Observable

At the end of the property-wrapper installment, I hinted that “not everything with an at sign is a wrapper.” @Observable is the example.

This continues from the previous article Swift Deep Dive #6.

It looks like a wrapper, but it is actually a macro. When we use #Preview in Xcode or @Model in SwiftData, we are already macro consumers.

This installment of the deep-dive series explains what macros introduced in Swift 5.9 are, what problems they solve, and what they cost. SE-0382: Expression Macros

What Macros Solve — The Last Stronghold of Boilerplate

Swift has steadily added ways to reduce repetitive code: protocol default implementations, automatic Codable synthesis, and property wrappers.

Yet one area remained out of reach: reading a type’s structure and generating code that matches it.

Observation is a good example. To notify observers when properties change, every stored property needs tracking code.

With a wrapper such as @Published, you had to add an at sign to every property, and any property you missed was silently excluded from observation.

What we need is: “Generate matching code for every stored property in this class.” That requires reading the type’s structure, which is beyond a wrapper’s capabilities.

Traditionally, two approaches handled this area: dynamic techniques in the Objective-C runtime, with runtime cost and opacity, or external code-generation tools such as Sourcery, managed separately outside the build pipeline.

Macros bring this work into the language itself—and into compile time.

How They Work — Code-Generation Plugins Embedded in the Compiler

Swift macros are compiler plugins. They are fundamentally different from the C preprocessor’s text substitution. The process works like this.

  1. When the compiler encounters macro usage (# or @) in the source, it passes that code’s syntax tree (AST) to the macro implementation.
  2. The macro implementation is a Swift program running in a separate process. It analyzes the tree with SwiftSyntax and returns newly generated code fragments.
  3. The compiler inserts the result at the original location, after which compilation proceeds normally.

This structure gives macros several important properties.

First, the result is real Swift code that can be inspected. In Xcode, right-click a macro and choose Expand Macro to see the generated code expanded in place.

Being able to lift the magic curtain at any time is the macro version of “hide, but don’t obscure,” from the Progressive Disclosure installment.

Second, macros are hygienic. Variables created by a macro are managed to avoid collisions with surrounding names, and a macro cannot modify code outside its declared role scope.

The notorious side effects of C macros are blocked at the design level.

Third, they are sandboxed. Macro processes cannot access files or the network, so they cannot become a security hole that runs arbitrary code at compile time.

A four-step diagram showing a syntax tree sent to a sandboxed plugin and generated code returned
The plugin receives a syntax tree and returns code; the result can be inspected with Expand Macro

Two Branches — freestanding (#) and attached (@)

Macros split into two branches based on how they attach.

A freestanding macro (#) generates code at its location. #Preview { MyView() } creates the preview registration structure.

Macros such as #URL("https://apple.com") validate strings at compile time and produce an unwrapped URL. They occupy the position where an expression would go.

An attached macro (@) expands the declaration it is attached to. Its roles are further divided.

These include member, which adds members; accessor, which adds accessors to properties; and extension, which adds protocol conformances. A single macro can serve multiple roles.

@Observable is exactly this combination. It adds observation-tracking accessors to each stored property of a class (accessor), adds registration-storage members (member), and adds conformance to the Observable protocol (extension).

That is why the inconvenience of adding an at sign to every property in the @Published era was reduced to one at sign on the type.

Once you know this distinction, library documentation becomes easier to read. Think of SwiftData’s @Model, #expect from the testing framework—the macro from the Swift Testing installment—and Composable Architecture’s @Reducer.

The major APIs in today’s ecosystem all belong to one of these two branches.

The Cost Sheet — Build One or Just Use One?

Macros have a clear cost, and consumers and producers pay different kinds of it.

For consumers, the main cost is usually build time. Macro implementations depend on SwiftSyntax, which is a large library.

That is why the first build of a package using macros includes compilation of SwiftSyntax in full.

It is common for a clean CI build to grow by several minutes, and the community continues refining mitigations such as distributing prebuilt binaries.

Even so, this is usually a reasonable cost for consumers. The expansion can be inspected with Expand Macro, and there is no runtime cost.

For producers, the cost is much higher. Writing macros means writing “code that handles code,” so the difficulty is a level higher.

You also need to learn the SwiftSyntax API, write macro-specific tests that compare input code with expected output, and design diagnostic messages.

In practice, a conservative standard is appropriate. If a problem can be solved with protocol default implementations, generics, or wrappers, start there.

A custom macro becomes a candidate only when “repetition that requires reading type structure” exists broadly across the team’s codebase. It is the macro version of YAGNI (You Aren’t Gonna Need It—the principle of not building something until it is needed).

For most teams, macros are not tools to build but tools to understand and use correctly when provided by a framework.

An illustration of the two macro branches: # stamps code in place, while @ expands a declaration
# creates code at its location, while @ expands the attached declaration

Summary

  • Macros are compiler plugins that receive a syntax tree at compile time and generate code (Swift 5.9). Unlike text-substituting C macros, they read type structure and run hygienically in a sandbox.
  • freestanding (#) creates code at its location (#Preview), while attached (@) expands the declaration it is attached to (@Observable, @Model).
  • The generated result is real Swift code that can be inspected at any time with Expand Macro. Not everything with an at sign is a wrapper, so you can now distinguish the word macro when reading documentation.
  • The costs are SwiftSyntax-driven build time for consumers and high implementation difficulty for producers. Build your own only when “broad repetition that requires reading type structure” has been confirmed. SE-0389: Attached Macros

The next installment covers concepts Swift brought from Rust’s territory: the noncopyable ~Copyable type, and the world of ownership opened by borrowing and consuming.

Sources and Verification Criteria

  • SE-0389: Attached Macros — Swift Evolution · original standards and specifications · verified 2026-08-17 · basis: attached macro roles and declarations they can generate
  • SE-0382: Expression Macros — Swift Evolution · original standards and specifications · verified 2026-08-17 · basis: the Swift 5.9 expression macro model and compiler plugins

Continue reading