Chapters

Hide chapters

Swift Internals

First Edition · iOS 26 · Swift 6.2 · Xcode 26

7. Metaprogramming
Written by Aaqib Hussain

The word “meta” is actually a Greek term that is commonly used as a prefix today. It means “beyond” or “after.” Metaprogramming refers to going “beyond” just writing code that runs. It’s not about the app logic itself—like performing a network request, developing UI, or building business logic—but about the powerful practice of writing code that can generate, analyze, or transform another piece of code. This is where the Swift language becomes a tool to manipulate your codebase.

Why should you care? This is the ultimate weapon against boilerplate code. The repetitive code you copy-paste for equality conformance, JSON decoding, or test mocks is a major source of bugs and is difficult to maintain. Here, metaprogramming acts as a knight in shining armor, enabling you to write a single piece of code that generates other repetitive code, ensuring consistency from a single source of truth. It also powers the creation of expressive, human-readable Domain-Specific Languages (DSLs).

This chapter explores three distinct metaprogramming methods in Swift, each with its own trade-offs along the spectrum of runtime flexibility and compile-time safety. You’ll start with runtime inspection using Mirror, which lets you peek inside any type while your app is running. Next, you’ll learn about compile-time transformation with @resultBuilder, the engine that turns simple Swift into complex data structures. Finally, you’ll gain hands-on experience with Swift Macros, a feature that generates code during compilation, eliminating entire categories of boilerplate with a single line. This chapter isn’t about hammering nails; it’s about building the hammer.

The Magic Mirror: Runtime Reflection with Mirror

In standard programming, you write code that operates on data. You are aware of the variables, their types, properties, and methods during compilation. But what if you want to see it while your code is running? What if you want to write a generic inspector that can examine any object? Whether it’s a struct User, an enum NetworkError, or even a class you haven’t implemented yet.

This is a common feature also available in languages other than Swift. It’s called Reflection. It refers to a program’s ability to inspect its structure such as types, relationships, and properties at runtime. In Swift, the primary tool to effectively leverage this capability is Mirror.

What is Reflection?

Reflection is a kind of metaprogramming that happens only at runtime. Unlike compilation tools that validate code before execution, reflection examines your app’s objects in memory while they are active.

You can think of it as holding a mirror up to your code. Usually, a function only sees the values it’s passed. With reflection, you can see the structure of those values and answer questions such as:

  • What kind of thing are you? (A struct? A class? A tuple?)
  • What are the names of your properties?
  • What values are currently stored in those properties?

Swift intentionally limits reflection capabilities to preserve performance and type safety. Unlike Objective-C, Swift reflection does not allow method invocation, mutation, or dynamic type creation at runtime. However, Mirror offers a standardized, safe way to peek inside instances when you truly need that dynamic behavior.

How to Use Mirror?

Using a Mirror is quite simple. You create a Mirror to reflect any instance you want to inspect.

Consider the following code:

struct User {
  let name: String
  let age: Int
}

let michael = User(name: "Michael Scott", age: 44)

// Create the mirror
let mirror = Mirror(reflecting: michael)

Once you have the mirror object, one of its most useful properties is children. This is the collection of all visible parts of the reflected subject. Each child is a tuple containing an optional label (the property name) and a value (the stored data).

Iterating over the children collection:

print("Inspecting \(mirror.subjectType):")
for child in mirror.children {
  let propertyName = child.label ?? "unknown"
  print("  - \(propertyName): \(child.value)")
}
// Output:
// Inspecting User:
//   - name: Michael Scott
//   - age: 44

You can also identify the type of an object. It’s an optional enum that can be .struct, .class, .enum, .tuple, .optional, .collection, and more, by using the displayStyle property on the Mirror object. It is important to be aware of the type you’re dealing with when handling values. For example, you might want to format a .class differently than a .tuple in a logging tool.

Practical Use Case: Building a Generic prettyPrint

The best way to use Mirror is to create something useful with it. A common issue in debugging is printing complex objects, resulting in unreadable, jumbled output. You can use Mirror to write a generic function that uses reflection to recursively print any object with clear indentation.

This function doesn’t need prior knowledge of the types it will print. It will use Mirror to figure it out dynamically.

Take a look at the function below:

func prettyPrint(_ value: Any, indent: Int = 0) {
    let mirror = Mirror(reflecting: value)
    
    // Base case: If the value has no children, just print the it directly.
    if mirror.children.isEmpty {
        print(value)
        return
    }
    
    // Determine if it's a collection to use [] instead of ().
    let isCollection = mirror.displayStyle == .collection || mirror.displayStyle == .set || mirror.displayStyle == .dictionary
    let open = isCollection ? "[" : "("
    let close = isCollection ? "]" : ")"
    
    // Print type name (if not a collection) and opening bracket.
    if !isCollection {
        print("\(mirror.subjectType)", terminator: "")
    }
    print(open)
    
    let childIndent = String(repeating: "  ", count: indent + 1)
    
    for child in mirror.children {
        // Always print indentation first
        print(childIndent, terminator: "")
        
        // If it has a label (like struct properties), print it.
        // Arrays usually don't have labels for their elements.
        if let label = child.label {
            print("\(label): ", terminator: "")
        }
        
        // Recurse for the value
        prettyPrint(child.value, indent: indent + 1)
    }
    
    // Print closing bracket with parents’ indentation.
    let footerIndent = String(repeating: "  ", count: indent)
    print("\(footerIndent)\(close)")
}

Now, you can pass anything to this function:

struct Company {
  let boss: User
  let employees: [User]
}

let dunderMifflin = Company(
  boss: User(name: "Michael", age: 44),
  employees: [
    User(name: "Jim", age: 33),
    User(name: "Dwight", age: 38)
  ]
)

prettyPrint(dunderMifflin)

The prettyPrint function implements recursion and utilizes Mirror. It will traverse deeply into the Company struct, locate the boss property, verify that it is of type User, and continue digging.

It yields the following output:

Company(
  boss: User(
    name: Michael
    age: 44
  )
  employees: [
    User(
      name: Jim
      age: 33
    )
    User(
      name: Dwight
      age: 38
    )
  ]
)

It’s a powerful, versatile implementation built entirely on runtime introspection.

The Limitations of the Mirror

While Mirror is a phenomenal tool, it comes with important trade-offs, especially compared to reflection in more dynamic languages.

First and foremost, Mirror is read-only. You can inspect an object, read its property names, and get its values, but you cannot modify them. You cannot use Mirror to set new values for the age property of the User object or change its name property. Swift’s strict emphasis on type safety and immutability prevents this kind of “backdoor” access.

Second, reflection is slow. Because it occurs entirely at runtime, it involves dynamic type checking, creating new collection wrappers for children, and boxing values into Any. It also prevents many compile-time optimizations that Swift normally relies on. While it’s perfect for debugging, logging, or serialization, you should never use Mirror in performance-critical tasks. It is a heavy, dynamic tool in a language optimized for static performance.

Dynamic Lookups: @dynamicMemberLookup

@dynamicMemberLookup lets you intercept accesses to members that don’t exist at compile time.

Normally in Swift, if you write someInstance.someProperty and someProperty doesn’t exist, the compiler throws an error and stops you immediately. This is a core safety feature. But what about when you’re working with inherently dynamic data, like JSON, where the keys are unknown until runtime? @dynamicMemberLookup makes this possible by letting you create clean, dot-syntax APIs over data that is inherently unstructured.

What is @dynamicMemberLookup?

@dynamicMemberLookup is an attribute that you can apply to a struct, class, or enum. It fundamentally alters how the compiler handles property access on that type.

When you apply this attribute, you’re making a promise to the compiler. “Hey compiler, if you see someone try to access a property on this type that you don’t recognize, don’t throw an error. Instead, just trust me. At runtime, I will provide an implementation that handles that call.” This lets you adopt dynamic behavior found in languages like Python or JavaScript, but in a controlled, explicit way.

Applying the @dynamicMemberLookup Attribute

To fulfill the promise with the compiler, the type marked with @dynamicMemberLookup must implement a special subscript method: subscript(dynamicMember member: String). The String parameter represents the member name extracted from the dot syntax.

When the compiler encounters a dot-syntax access to an unresolved member, it rewrites that expression into a call to this subscript. That’s where the compiler performs this magic translation.

Check the following example of using the @dynamicMemberLookup attribute on a dynamic dictionary.

@dynamicMemberLookup
struct DynamicDictionary {
  private var data: [String: Any]
  
  init(_ data: [String: Any]) {
    self.data = data
  }
  
  // The required subscript
  subscript(dynamicMember member: String) -> Any? {
    print("Dynamic lookup for member: '\(member)'")
    return data[member]
  }
}

Now you can call the properties like:

let user = DynamicDictionary(["name": "Jim Halpert", "age": 33])

// 1
let name = user.name
// 2
print(name)

A quick analysis of each line is as follows:

  1. The dot syntax is available, and the compiler doesn’t give you an error.

  2. It prints:

    • Dynamic lookup for member: 'name'

    • Optional("Jim Halpert")

This compiler trick is fundamental to dynamic member lookup. It secretly converts simple dot-syntax (user.name) into string-based dictionary lookups (user[dynamicMember: “name”]), providing the best of both worlds.

Practical Use Case: A Type-Safe JSON Wrapper

The most common and powerful use case for a @dynamicMemberLookup is building a wrapper that makes JSON-style access cleaner and more ergonomic.

You should be aware of the pyramid of doom. The indentation gets so deep that you practically need a climbing rope and a headlamp to find your way back out. It’s typically caused by nested casting when accessing dictionary values. The code usually looks like this:

// The "Before" - Painful, nested casting
var userName: String?
if let userDict = json["user"] as? [String: Any] {
  if let nameValue = userDict["name"] as? String {
    userName = nameValue
  }
}

This is difficult to read and very fragile. You can improve this by creating a JSON struct that wraps your data and uses @dynamicMemberLookup for a clean, chainable approach. Take a look at the code below:

@dynamicMemberLookup
struct JSON {
  private var data: Any?
  
  init(_ data: Any?) {
    self.data = data
  }
  
  subscript(dynamicMember member: String) -> JSON {
    guard let dict = data as? [String: Any] else {
      return JSON(nil)
    }
    return JSON(dict[member])
  }
  
  var string: String? {
    return data as? String
  }
  
  var int: Int? {
    return data as? Int
  }
  
  var array: [JSON]? {
    guard let arr = data as? [Any] else { return nil }
    return arr.map { JSON($0) }
  }
}

Now, you can use this wrapper like this:

// The "After" - Clean, chainable, and readable
let userData: [String: Any] = [
  "user": [
    "name": "Michael G. Scott",
    "age": 44
  ]
]

let json = JSON(userData)
let name = json.user.name.string
print(name) // Prints Optional("Michael G. Scott")

To understand what is happening here:

  1. json.user: The compiler cannot find the user property. It calls the subscript(dynamicMember: "user"). This retrieves the user object from the provided userData dictionary and returns a new JSON struct that wraps that dictionary.
  2. .name: This is called on the new JSON struct. The compiler again calls subscript(dynamicMember: "name"). This finds the name property within the user object and returns a new JSON object containing only that string.
  3. .string: This is a standard property call on the JSON struct. It attempts to cast its internal data ("Michael G. Scott") to a String and returns it.

This is a great example of metaprogramming in practice: you built a tool that enables clean, chainable, API-like syntax while dynamically managing unstructured data at runtime.

The DSL Factory: Mastering Result Builders

Inspecting objects during runtime is interesting; what’s even more exciting is working with powerful compile-time metaprogramming concepts. This is where the code’s structure is modified as it’s being compiled.

One of the most elegant and widely used examples in Swift is the Result Builder system. It’s what makes SwiftUI APIs feel declarative and natural, and it allows you to build your own Domain-Specific Languages (DSLs) directly in Swift.

What is a Domain Specific Language (DSL)?

A Domain Specific Language (DSL) is a small language created for a specific task. Swift is a general-purpose language; you can use it to build anything, from watch apps to web servers. In contrast, a DSL is highly focused, offering a limited set of commands and a specific syntax that makes it very expressive for one particular domain.

A common example in the Apple ecosystem is SwiftUI. When you write a SwiftUI view, you’re not writing typical imperative Swift, you’re using a DSL.

Think about the difference. Without a DSL, you typically build UI hierarchies imperatively:

// The "old" way (imperative)
let text = Text("Hello")
let image = Image("icon")
let stack = VStack()
stack.addArrangedSubview(text)
stack.addArrangedSubview(image)
return stack

This feels awkward. You’re concentrating on the how, creating instances and calling methods.

SwiftUI’s DSL, powered by @ViewBuilder, allows you to write this declaratively:

// The DSL way (declarative)
VStack {
  Text("Hello")
  Image("icon")
}

This marks a significant shift in how you express intent. The code mirrors the hierarchy it creates: it’s clean, readable, and emphasizes the what over the how. That’s the goal of a good DSL—providing a high-level description of the desired outcome instead of a low-level sequence of steps that the reader must mentally reconstruct. You can see this pattern in HTML for document structure or SQL for database queries. In Swift, @resultBuilder is the attribute that enables you to craft these expressive mini-languages.

Introducing @resultBuilder

So how does a simple list of views become a complex, combined view? The answer you’re looking for is the @resultBuilder attribute.

You use this attribute when declaring a class, struct, enum, or actor. It instructs the compiler: “When you encounter this attribute, apply a predefined set of transformation rules to the statements inside it.” It’s a transformer that the compiler uses to rewrite your code behind the scenes.

It takes a sequence of normal Swift statements and converts them, one by one, into a single combined value.

That beautiful, declarative SwiftUI code you wrote:

VStack {
  Text("Hello")
  Image("icon")
}

…is just syntactic sugar. Because VStack’s content parameter is marked with @ViewBuilder, the compiler actually sees this and rewrites it into something like this behind the scenes:

VStack(content: {
  let view1 = Text("Hello")
  let view2 = Image("icon")
  return ViewBuilder.buildBlock(view1, view2)
})

The purpose of the result builder is to implement a set of static methods (like buildBlock) that define how the statements passed to them are transformed. It’s like a machine in a factory that takes in raw materials to produce a finished product.

Using a Result Builder: buildBlock

To grasp the concept of this attribute, you’re going to build a simple example ArrayBuilder whose only job is to take a list of items and wrap them in an array.

First, define a builder struct.

@resultBuilder
struct ArrayBuilder<T> {
  // This is the most important method.
  // It takes a list of components and combines them.
  static func buildBlock(_ components: T...) -> [T] {
    print("buildBlock called with \(components.count) items")
    return components
  }
}

You’ve defined a builder, ArrayBuilder, and implemented the one static method it needs to combine multiple components: buildBlock. Now, create a function that uses this builder:

func buildArray<T>(@ArrayBuilder<T> content: () -> [T]) -> [T] {
  return content()
}

let numbers = buildArray {
  1
  2
  3
}

print(numbers)

When you run this, the console prints:

buildBlock called with 3 items 
[1, 2, 3]

This demonstrates what the compiler did. It recognized the sequence of statements 1, 2, 3 inside the closure marked with @ArrayBuilder and converted it into a single function call: ArrayBuilder.buildBlock(1, 2, 3). This buildBlock method is the core component of all result builders.

Adding Logic

A DSL that only supports static elements is limited. The real power of a result builder emerges when you add support for control flow, like if and else statements.

However, introducing logic creates a type challenge. In the previous example, you were just passing single items T. But an if statement might return a value, or might not return anything. To handle this variability cleanly, you apply a normalization strategy: convert everything to an array [T] before combining.

This requires the implementation of buildExpression to wrap single elements into arrays, and updating buildBlock to accept [T]... instead of single elements. Once the foundation is in place, you can implement control flows.

Handling if Statements

When you write if condition { value }, the compiler first runs the expression inside the branch through buildExpression, turning it into [T]. It then calls buildOptional.

Since the input is now an array, buildOptional receives [T]?. If the condition is false, the input is nil. You implement buildOptional to handle this by returning an empty array in the nil case, ensuring buildBlock always receives a valid list to flatten.

@resultBuilder
struct ArrayBuilder<T> {
  static func buildExpression(_ expression: T) -> [T] {
    print("buildExpression called")
    return [expression]
  }
  
  // 2. Accept variadic arrays and flatten them
  static func buildBlock(_ components: [T]...) -> [T] {
    print("buildBlock called with \(components.count) items")
    return components.flatMap { $0 }
  }
  
  // This enables: if condition { ... }
  static func buildOptional(_ component: [T]?) -> [T] {
    print("buildOptional called")
    return component ?? []
  }
}

Now, if you change your original code to the following:

var showExtra = false
let numbers = buildArray {
  1
  2
  if showExtra {
    3
  }
}

It prints in the console:

buildBlock called with 3 items 
[1, 2]

Although the closure contains three statements, the final result includes only the elements from the branches that produced values.

Handling if-else Statements

For handling if-else or switch statements, two other functions come into play that the compiler uses: func buildEither(first component: [T]) -> [T] and func buildEither(second component: [T]) -> [T]. Again, because of normalization, these methods receive and return [T].

They act as simple pass-through, satisfying the compiler’s requirement to unify the types coming from different code branches into a single type that buildBlock can process.

static func buildEither(first component: [T]) -> [T] {
  return component
}

static func buildEither(second component: [T]) -> [T] {
  return component
}

It only works when all these methods: buildExpression, buildBlock, buildOptional, and buildEither are present and aligned on the [T] type that the compiler can successfully transform complex logic into a flat array.

@resultBuilder
struct ArrayBuilder<T> {
  // 1. Normalize single items to arrays
  static func buildExpression(_ expression: T) -> [T] {
    print("buildExpression called")
    return [expression]
  }
  
  // 2. Accept variadic arrays and flatten them
  static func buildBlock(_ components: [T]...) -> [T] {
    print("buildBlock called with \(components.count) items")
    return components.flatMap { $0 }
  }
  
  // 3. Logic methods must now work with [T]
  static func buildOptional(_ component: [T]?) -> [T] {
    return component ?? []
  }
  
  static func buildEither(first component: [T]) -> [T] { return component }
  static func buildEither(second component: [T]) -> [T] { return component }
}

Now, if you return to the buildArray function and include an else condition like this:

var showExtra = false
let numbers = buildArray {
  1
  2
  if showExtra {
    3
  } else {
    4
  }
}

You should see the console printing the following:

[1, 2, 4]

With these methods, ArrayBuilder can now handle full conditional logic, just like SwiftUI’s ViewBuilder. It’s a simpler builder compared to ViewBuilder. In ViewBuilder, these methods wrap the two different view types in a special internal _ConditionalContent view, ensuring the entire if-else expression resolves to a single, consistent type.

Practical Use Case: Building a Simple HTMLBuilder

You can use what you’ve learned about result builders and put it into practical use by developing an expressive DSL for generating HTML strings. The goal is to write Swift that reads like HTML.

Step 1: Define the Builder: The builder will build String components. The buildBlock function concatenates all the strings with newlines. Also, you add buildEither and buildOptional methods so you can use if and else statements.

@resultBuilder
struct HTMLBuilder {
  static func buildBlock(_ components: String...) -> String {
    components.joined(separator: "\n")
  }
  
  static func buildOptional(_ component: String?) -> String {
    component ?? ""
  }
  
  static func buildEither(first component: String) -> String { component }
  static func buildEither(second component: String) -> String { component }
}

Step 2: Define Helper Functions: You’ll create helper functions that wrap content in HTML tags. These functions form the vocabulary of your DSL.

func html(@HTMLBuilder content: () -> String) -> String {
  "<html>\n\(content())\n</html>"
}

func body(@HTMLBuilder content: () -> String) -> String {
  "<body>\n\(content())\n</body>"
}

func p(_ content: String) -> String {
  "<p>\(content)</p>"
}

func h1(_ content: String) -> String {
  "<h1>\(content)</h1>"
}

Note that html and body annotate their closure parameters with @HTMLBuilder, enabling the multi-statement DSL syntax inside those closures.

Step 3: Use the DSL: You can now write clear, declarative code to generate an HTML document.

You can use it like this:

let isLoggedIn = true

let myPage = html {
  body {
    h1("Welcome to our site!")
    if isLoggedIn {
      p("You are logged in.")
    } else {
      p("Please log in to continue.")
    }
    p("This is a DSL-powered website.")
  }
}

print(myPage)

It produces an HTML string like this:

<html>
<body>
<h1>Welcome to our site!</h1>
<p>You are logged in.</p>
<p>This is a DSL-powered website.</p>
</body>
</html>

This demonstrates the power of result builders. You’ve designed a compile-time transformation system that turns readable Swift into a structured HTML string. You’ve built the factory, now you can use it to produce consistent output with a clean call site.

The New Frontier: Swift Macros

For years, Swift developers have chased the Holy Grail of clean code: eliminating boilerplate. In pursuit of this, they have used inheritance, protocol extensions, and generic constraints to reduce repetition. Yet, you still find yourself writing CodingKeys manually at times, creating endless mocks for testing, or wrapping legacy completion handlers to work with async code.

With Swift 5.9, Apple has given the developer community the keys to the compiler itself. Swift Macros represent one of the biggest shifts in Swift metaprogramming so far. They aren’t just a convenience feature; they change how libraries and architecture patterns can be expressed with less boilerplate.

What are Macros?

At its core, a macro is a compile-time transformation that can be invoked either as an attribute (attached macros) or as an expression (freestanding macros) to generate Swift code.

When the compiler encounters a macro invocation, it expands it during compilation by running the macro implementation (provided by a compiler plugin). The macro inspects the relevant syntax, generates new Swift code, and the compiler then compiles the expanded result alongside your original source.

To understand why this is revolutionary, compare it with the tools you used before: Mirror and @resultBuilder.

Macros vs. Mirror

You used Mirror to dynamically inspect a type’s properties (e.g., for JSON parsing or logging).

  • The Problem: Mirror operates at runtime. It can be slow, it hides structure from the compiler, and failures tend to show up late, as missing keys, unexpected shapes, or type mismatches during execution rather than at build time.

  • The Macro Solution: Macros run at compile time. They can generate code before the app runs, which means no runtime reflection cost, and errors surface as build failures instead of production surprises.

Macros vs. @resultBuilder

Result builders (introduced in SwiftUI) enable transforming a sequence of statements into a single value.

  • The Limitation: Result builders transform a block of statements into a single value within that expression context. They don’t create new declarations (types, methods, conformances), and they don’t perform general-purpose structural code generation.

  • The Macro Solution: Macros leverage the SwiftSyntax library, which has access to the Abstract Syntax Tree (AST). They can read variable names, function types, and access levels of structs, then generate new declarations based on that data.

Type 1: Freestanding Macros

The first type of macro is a Freestanding Macro. They appear in your code as expressions that start with a hash symbol (#). They behave somewhat like functions, but instead of being executed at runtime, they expand at compile time into ordinary Swift expressions that may produce runtime values.

Freestanding macros are unique because they do not attach to a specific declaration, such as a struct or class; they stand alone within the code flow.

The Problem: Runtime Validation

Consider the common task of creating a URL from a string.

// The old way
let url = URL(string: "https://www.apple.com")!

You often use force-unwrapping because you know the string is static and correct. However, the compiler does not know that. It requires you to handle an optional that you believe won’t be nil, risking a crash.

The Solution: The #URL Macro

A freestanding macro can validate the string during compilation.

// For illustration, imagine a #URL macro:
let url = #URL("https://www.apple.com")

How it works:

  1. Analysis: The macro reads the string literal “https://www.apple.com”.

  2. Validation: It verifies whether this string is a correctly formatted URL.

    • If it is invalid (e.g., #URL("http :// bad")), the macro produces a compile-time error, causing the build to fail. You can’t ship a bug.
    • If it is valid, the macro expands into a URL-producing expression (often equivalent to URL(string: "https://www.apple.com"), but guaranteed by compile-time validation).
  3. Result: You obtain the safety of a non-optional type, with confidence that the URL is correctly formed.

Type 2: Attached Macros

Another powerful category of macros is attached macros. These are identified by the @ symbol (e.g., @Observable, and @Model from SwiftData).

Attached macros don’t replace your source; they augment it. They attach to declarations (types, members, functions, variables) and can generate additional code—such as new members or synthesized conformances—within the appropriate scope.

The Problem: Legacy Boilerplate

A significant challenge in modern iOS development is bridging the gap between legacy callback-based APIs and modern Swift Concurrency (async/await).

Imagine you have a legacy networking service:

class NetworkService {
  func fetchUserProfile(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    // complex legacy networking logic...
  }
}

To use this with async/await, you need to write a wrapper using withCheckedContinuation manually. This process is tedious, error-prone, and repetitive.

The Solution: The @GenerateAsync Macro

You can create an attached macro called @GenerateAsync. When attached to a function, it analyzes the function signature, detects the completion handler, and automatically generates the async version.

class NetworkService {
  @GenerateAsync
  func fetchUserProfile(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    // legacy logic
  }
}

Macro Expansion (Generated Code): This macro automatically creates a “peer” function in the background.

// Generated by @GenerateAsync
extension NetworkService {
  func fetchUserProfile(id: String) async throws -> User {
    return try await withCheckedThrowingContinuation { continuation in
      self.fetchUserProfile(id: id) { result in
        continuation.resume(with: result)
      }
    }
  }
}

You never need to write the continuation logic. If the original function’s signature changes, the macro automatically updates the async version the next time you build.

Why Macros are a Game-Changer

Swift Macros are more than just a convenience; they represent a fundamental shift in how Swift libraries can be designed.

The End of Boilerplate

The primary goal for developers is to write business logic, not boilerplate code. Macros address the boilerplate problem by allowing library creators to write foundational code once and have it automatically replicated by the compiler. From the @Observable macros in SwiftUI to SwiftData’s @Model, Apple already demonstrates that macros are becoming the standard for reducing code verbosity.

Consistency and Safety

Humans tend to make mistakes and copy-and-paste errors; compilers do not. When you manually conform to Codable or Equatable for complex types, you might overlook a property. A well-written macro won’t overlook a property, and it can enforce that generated code stays aligned with the source declaration.

White Box Magic

Historically, code-generation tools in iOS were opaque: you ran a script, and a file appeared. Swift macros are now integrated into Xcode. You can right-click a macro and select “Expand Macro” to see exactly what code is being generated. This transparency builds trust; you’re not relying on magic. Instead, you rely on code that you can see, debug, and understand.

Swift Macros shift complexity from the application layer to the compiler layer, often resulting in codebases that are safer, faster, and easier to read.

Key Points

  • Metaprogramming is a technique for writing code that creates, examines, or modifies other code, rather than just executing application logic.
  • Metaprogramming is the best way to eliminate boilerplate, reduce copy-paste mistakes, and create a single source of truth for repetitive logic.
  • Mirror enables a program to examine its own structure (properties, types, and values) during execution.
  • You create a Mirror(reflecting: instance) to access the children property, which allows you to iterate over labels and values dynamically.
  • Mirror enables the creation of generic tools, such as a recursive prettyPrint function, that can handle any type without knowing its structure in advance.
  • Reflection in Swift is read-only (you cannot modify values) and is computationally expensive; it should be avoided in performance-critical loops.
  • @dynamicMemberLookup lets you access properties with dot syntax (for example, object.name), even if those properties aren’t available at compile time. The compiler converts dot-syntax calls into a specific subscript call: subscript(dynamicMember: String). It bridges the gap between Swift’s strict type safety and dynamic data, making it ideal for creating clean wrappers around JSON, dictionaries, or scripts.
  • @resultBuilder powers SwiftUI by allowing the creation of Domain-Specific Languages (DSLs) where code specifies what to do, not how to do it. This attribute converts a sequence of distinct statements (such as a list of Views) into a single combined value.
  • Every result builder must implement static func buildBlock(…), which specifies how components are combined.
  • To support logic like if and else within a DSL, the builder must implement methods such as buildOptional and buildEither.
  • Introduced in Swift 5.9, Macros run at compile time to create and insert new code into your source files, with no runtime reflection cost.
  • Unlike result builders, Macros interact with the Abstract Syntax Tree through SwiftSyntax, enabling them to examine types in detail and create entirely new declarations.
  • Freestanding macros stand alone (like #URL) and act as expressions that return a value or perform validation, effectively replacing runtime crashes with compile-time errors.
  • Attached macros are applied to declarations (like @GenerateAsync) and enhance code by adding new methods, properties, or conformances to existing types.

Where to Go From Here?

You have now stepped behind the curtain of the Swift language. Having been introduced to metaprogramming, you’ve progressed from simply using Apple’s tools to building your own. You understand that Mirror provides visibility during runtime inspection, @resultBuilder helps you create expressive DSLs, and Swift Macros enable code generation during compilation.

But power comes with responsibility. The risk of metaprogramming is over-engineering. Just because you can use a macro to generate a single line doesn’t mean you should.

Your next step is to think about what you’ve learned so far. Review your current project and identify the code you write repeatedly. Is it JSON parsing or mock data for tests? These are great options for macros. Also, consider replacing a complex configuration pattern in your app with a @resultBuilder to make the call site clearer.

If you’re serious about macros, explore the swiftlang/swift-syntax repository from GitHub, review the examples, and try creating a macro or two on your own.

Metaprogramming isn’t just a coding technique; it’s a way of thinking about how you build software. It encourages you to focus on the structure of your code rather than just the logic. As you grow, use the tools available to make your code cleaner, safer, and more expressive for everyone working with it.

If you feel tempted to write boilerplate code, remember the words of a wise man:

"Why waste time say lot word when few word do trick?"
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.