Skip to Content
Course content

134: Introduction to Swift Macros

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Wait, are macros just fancy functions that run at runtime?

This is the biggest misconception when people first see macros. No, they aren't. Unlike a function that executes while your app is running on a user's device, a Swift macro is a compile-time transformation. Think of it as a piece of code that writes other code for you before the compiler even finishes building your binary.

When you attach a macro to a variable or a function, the Swift compiler hands the source code over to the macro implementation, which then spits back a modified version of that code. By the time your app actually runs, the macro is gone—only the expanded code remains. This is why macros are so powerful for performance; there's zero runtime overhead for the "magic."

What's the actual difference between an "Attached" and a "Freestanding" macro?

You'll see these two terms everywhere in the documentation. The simplest way to remember it is by looking at the syntax: one uses an @ and the other uses a #.

  • Attached Macros: These are "stuck" to a declaration. They can add new properties, add conformances to protocols, or even add entirely new functions to a class or struct. For example, imagine a @Logged macro that automatically adds a logEntry() method to any class it's attached to.
  • Freestanding Macros: These stand on their own, usually inside a function body. They look like #expression. A great example is the built-in #stringify, which takes a value and turns it into a literal string of the code used to create that value.

I usually reach for Attached macros when I'm trying to kill boilerplate in my models, and Freestanding macros when I need to capture metadata about the code itself.

Do I really have to learn a whole new library just to write one?

Yeah, you do—and it's the part that usually scares people off. To write the implementation of a macro, you have to use SwiftSyntax. Instead of working with variables and loops, you're working with "Syntax Trees."

Essentially, the compiler views your code as a tree of nodes. A FunctionDeclSyntax node represents a function declaration; a VariableDeclSyntax represents a variable. To make a macro, you write a piece of code that traverses this tree and modifies the nodes. It feels a bit like doing surgery on your own source code.

// This is what the "expanded" code looks like to the compiler
// Original:
@Logged
func saveUser() { ... }

// Expanded (what the macro generated):
func saveUser() { ... }
extension User {
    func logSaveUser() { print("Entering saveUser...") }
}

It's a steep learning curve, but once you get the hang of the tree structure, you realize you have total control over how your code is generated.

When should I actually use a macro instead of just a protocol or a generic?

Be careful here. It's easy to get "macro fever" and try to automate everything. I always tell my juniors: if you can solve it with a protocol extension or a generic function, do that first. Macros are harder to debug and increase compile times.

Use a macro when you find yourself writing the exact same "boilerplate" patterns in dozens of different places—patterns that a protocol can't handle because they require adding actual members (like properties or methods) to a type dynamically. If you're spending your Friday afternoon copy-pasting the same five lines of setup code into twenty different structs, that's when you build a macro.




📋 Practical Task

Exercise: Designing a @Trace Method Macro

You are tasked with designing the logic for an Attached Macro called @Trace. This macro should be applied to functions. Instead of writing the full SwiftSyntax implementation (which requires a separate package), you will write the expansion logic.

Assume you have a function:

@Trace
func calculateTotal(price: Double, tax: Double) -> Double {
    return price + tax
}

In a text editor or a comment block, write out exactly what the expanded code should look like after the macro runs. Your expansion must:

  1. Keep the original function exactly as it is.
  2. Add a new function immediately following it called trace_calculateTotal().
  3. The new function should print the string: "Tracing: calculateTotal".

Once you've written the expansion, explain why this must be an Attached macro rather than a Freestanding macro.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.