Swift Macros in 2025: A Practical Guide

Revision as of 15:33, 13 November 2025 by Ryan (talk | contribs) (Created page with "<span id="swift-macros-in-2025-a-practical-guide"></span> = Swift Macros in 2025: A Practical Guide = <blockquote>Updated for '''Swift 6.2''' (released Sep 15, 2025). If you’re on Swift 5.9+ the fundamentals are the same; Swift 6.x added polish and tooling, not a new macro model. ([https://www.swift.org/blog/swift-6.2-released/?utm_source=chatgpt.com Swift.org]) </blockquote> ----- <span id="table-of-contents"></span> == Table of contents == * #1-what-are-macros-...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Swift Macros in 2025: A Practical Guide

Updated for Swift 6.2 (released Sep 15, 2025). If you’re on Swift 5.9+ the fundamentals are the same; Swift 6.x added polish and tooling, not a new macro model. (Swift.org)


Table of contents



1. What are macros & why Swift added them

Macros generate Swift code at compile time. The compiler expands macro calls into real Swift that’s type-checked like anything you write by hand. Macro expansion is additive—it can insert code, not delete or mutate your existing source—so you can reason about the result and keep tooling stable. (Swift Documentation)

They sit next to language features like result builders or property wrappers: wrappers transform storage & access of one declaration; macros can generate new declarations, conformances, or expressions—reducing boilerplate while preserving clarity. (Swift Documentation)



2. Macro kinds (freestanding vs. attached)

Swift defines two families: (Swift Documentation)

  • Freestanding macros appear by themselves and start with # (e.g. #fileID, #warning). They produce an expression, statement, or declaration right at the call site. (Swift Documentation)
  • Attached macros are attributes (e.g. @Something) applied to a declaration and can add members, accessors, peers, or extensions (with conformances). These are specified via roles in the macro declaration. (GitHub)

Syntax at a glance

// Freestanding
let path = #fileID   // standard library macro
#warning("This compiles, but double-check input") // emits a diagnostic

// Attached (examples you might see in Apple frameworks or libraries)
@Observable
final class Model { /* ... */ }    // expands into storage/observation plumbing

@SomeMacro
struct Foo { /* macro adds members or conformances */ }

Apple’s docs cover how freestanding calls insert code at the call site and how attached macros modify the annotated declaration. (Apple Developer)



3. Defining & registering macros

Authoring flow (SPM):

  1. Create a macro package:
swift package init --type macro

This scaffolds a macro implementation target, a library that exposes your macro declarations, and tests. (Swift Documentation)

  1. In the declarations target, you declare public macros and point them to their implementation with #externalMacro(module:type:). (Apple Developer)
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
  #externalMacro(module: "ExampleMacros", type: "StringifyMacro")
  1. In the implementation target you implement types that conform to macro protocols (e.g. ExpressionMacro, MemberMacro, ExtensionMacro) and register them in a compiler plugin:
import SwiftCompilerPlugin
@main
struct ExamplePlugin: CompilerPlugin {
  let providingMacros: [Macro.Type] = [StringifyMacro.self]
}

SPM exposes a dedicated .macro target kind and the CompilerPluginSupport helpers to link everything up. These targets are built for the host (e.g., macOS) and typically depend on SwiftSyntax/SwiftCompilerPlugin. (GitHub)

Tip: Your macro package often sets minimum platform versions for the macro target (host), e.g., .macOS(.v13), even if your library is for iOS. This resolves common “external macro type could not be found” build errors. (Stack Overflow)


4. Common use-cases

  • Boilerplate removal & codegen Generate CodingKeys, Equatable/Hashable implementations, @Observable storage, @AddCompletionHandler peers, or whole extensions via the extension macro role. (GitHub)
  • Compile-time validation Validate URLs, regexes, bundle resources, etc., and emit diagnostics at build time. (Example: a #URL("…") macro that constructs URL(...)! after verifying the string.) (Vodafone tech)
  • Diagnostics & logging aids Macros can throw MacroExpansionErrorMessage, emit warnings/errors, and attach Fix-Its to guide users. (Stack Overflow)



5. Step-by-step: build two macros

We’ll create a package with:

  • #stringify: a freestanding expression macro returning (value, "source code").
  • @AddDescription: an extension macro that adds CustomStringConvertible conformance and a description computed property for a type’s stored properties.

Package layout

Package.swift (abbreviated):

// swift-tools-version: 6.2
import PackageDescription
import CompilerPluginSupport

let package = Package(
  name: "MacroPlayground",
  platforms: [.macOS(.v13), .iOS(.v17)],            // host + client minimums
  products: [
    .library(name: "Macros", targets: ["Macros"]),  // declarations client imports
    .macro(name: "MacroImplementations", targets: ["MacroImplementations"])
  ],
  dependencies: [
    .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.1")
  ],
  targets: [
    .target(name: "Macros", dependencies: ["MacroImplementations"]),
    .macro(
      name: "MacroImplementations",
      dependencies: [
        .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
        .product(name: "SwiftSyntax", package: "swift-syntax"),
        .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
        .product(name: "SwiftSyntaxMacros", package: "swift-syntax")
      ]
    ),
    .testTarget(
      name: "MacroTests",
      dependencies: [
        "MacroImplementations",
        .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
      ]
    )
  ]
)

Notes: .macro is the SPM target for compiler plugins; SwiftSyntax is the API you use to read/emit syntax trees during expansion. (GitHub)


5.1 #stringify (freestanding)

DeclarationSources/Macros/Stringify.swift

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
  #externalMacro(module: "MacroImplementations", type: "StringifyMacro")

ImplementationSources/MacroImplementations/StringifyMacro.swift

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct StringifyMacro: ExpressionMacro {
  public static func expansion(
    of node: some FreestandingMacroExpansionSyntax,
    in context: some MacroExpansionContext
  ) -> ExprSyntax {
    guard let expr = node.argumentList.first?.expression else {
      context.diagnose(Diagnostic(node: Syntax(node),
        message: SimpleError("`#stringify` needs exactly one expression")))
      return "(\(literal: ""), \(literal: ""))"
    }
    // Build: ( <expr>, "<source>" )
    return "(\(expr), \(literal: expr.description))"
  }
}

// A tiny helper for diagnostics
struct SimpleError: Error, DiagnosticMessage {
  let message: String
  var diagnosticID: MessageID { .init(domain: "MacroPlayground", id: "stringify") }
  var severity: DiagnosticSeverity { .error }
}

Use it — in any client module that imports Macros:

import Macros

let (value, source) = #stringify(2 + 3)
// value == 5, source == "2 + 3"

The #externalMacro hook names the module and type the compiler should invoke for expansion. (Apple Developer)



5.2 @AddDescription (attached extension macro)

DeclarationSources/Macros/AddDescription.swift

@attached(
  extension,
  conformances: CustomStringConvertible,
  names: named(description)
)
public macro AddDescription() =
  #externalMacro(module: "MacroImplementations", type: "AddDescriptionMacro")

ImplementationSources/MacroImplementations/AddDescriptionMacro.swift

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct AddDescriptionMacro: ExtensionMacro {
  public static func expansion(
    of node: AttributeSyntax,
    attachedTo declaration: some DeclGroupSyntax,
    providingExtensionsOf type: some TypeSyntaxProtocol,
    conformingTo protocols: [TypeSyntax],
    in context: some MacroExpansionContext
  ) throws -> [ExtensionDeclSyntax] {

    // Support classes & structs
    guard let decl = declaration.as(StructDeclSyntax.self) ??
                     declaration.as(ClassDeclSyntax.self) else {
      context.diagnose(
        Diagnostic(node: Syntax(declaration),
        message: SimpleError("@AddDescription works on structs/classes only"))
      )
      return []
    }

    // Collect stored property identifiers
    let names = decl.memberBlock.members
      .compactMap { $0.decl.as(VariableDeclSyntax.self) }
      .filter { $0.bindings.allSatisfy { $0.accessorBlock == nil } } // stored only
      .compactMap { $0.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text }

    // "TypeName(a: \(a), b: \(b))"
    let pairs = names.map { "\\(\($0)): \\(\(raw: $0))" }.joined(separator: ", ")
    let typeName = type.trimmed

    let ext: DeclSyntax = """
    extension \(typeName): CustomStringConvertible {
      public var description: String {
        "\(typeName)(\(raw: pairs))"
      }
    }
    """
    return [ext.cast(ExtensionDeclSyntax.self)!]
  }
}

struct SimpleError: Error, DiagnosticMessage {
  let message: String
  var diagnosticID: MessageID { .init(domain: "MacroPlayground", id: "adddescription") }
  var severity: DiagnosticSeverity { .error }
}

Use it — in client code:

import Macros

@AddDescription
struct User {
  let id: Int
  let name: String
}

print(User(id: 1, name: "Ryan"))
// → User(id: 1, name: Ryan)

Attached macro roles and the names:/conformances: requirements are specified in the Swift book and proposals; extension macros come from SE-0402. (Swift Documentation)



6. Debugging, testing & tooling

  • See the expanded code in Xcode Place the caret on a macro use and choose Editor → Expand Macro to inline the generated code (undo to revert). Great for understanding and setting breakpoints in generated code. (Apple Developer)
  • CLI & vision docs The macro vision notes a -Xfrontend -dump-macro-expansions-style capability; availability can vary by toolchain. Prefer Xcode’s “Expand Macro” for stable flows. (Gist)
  • Unit test your macros Use SwiftSyntaxMacrosTestSupport.assertMacroExpansion or community tools like pointfreeco/swift-macro-testing for snapshot-style assertions of expansion & diagnostics. (SwiftLee)
  • Diagnostics Emit structured diagnostics (MacroExpansionErrorMessage, warnings, Fix-Its) from within expansion to guide users at compile time. (Stack Overflow)
  • Build trust prompts & CI Xcode requires trusting third-party macro packages (including on Xcode Cloud/CI). Pin platforms for the macro target and ensure the CI host OS matches your macro’s host minimum. (Stack Overflow)



7. Limitations & best practices

  • Additive only: macros insert code; they don’t mutate existing user code in place. That keeps macro use auditable and tool-friendly. (Swift Documentation)
  • No function-body rewriting in stable Swift: proposals for function body macros (SE-0415) were returned for revision—avoid designs that depend on rewriting existing bodies. Prefer generating peers/extensions. (Swift Forums)
  • Declare what you generate: attached macro roles often require names: to list generated symbols; extension macros must declare any conformances. This helps the compiler and IDEs stay accurate. (Swift Documentation)
  • Mind host vs. target: macro implementations build for the host platform (e.g., macOS). Keep your macro target’s platform settings consistent with your CI machines. (Swift Forums)
  • Emit precise diagnostics: fail early with friendly messages; prefer compile-time validation over runtime traps. (Stack Overflow)
  • Keep expansions small & predictable: readability wins. If a macro would output pages of code, consider a generator step or a library API instead.



8. Conclusion

Swift macros let you remove repetition, encode conventions, and shift many mistakes from runtime to compile time—all while staying within Swift’s type system and tooling. Use them to generate members, peers, and extensions; validate inputs early; and standardize boilerplate. But keep things predictable and auditable—don’t overuse macros when a plain API or property wrapper suffices. (Swift Documentation)



References & further reading

  • The Swift Programming Language — Macros (official) (Swift Documentation)
  • Apple Developer: Applying Macros (how to use + “Expand Macro”) (Apple Developer)
  • #externalMacro(module:type:) (Apple API reference) (Apple Developer)
  • SE-0382 Expression Macros (accepted) (GitHub)
  • SE-0389 Attached Macros (accepted) (GitHub)
  • SE-0402 Extension Macros (accepted) (GitHub)
  • SwiftSyntax & macro protocols (foundation for macro impls) (GitHub)
  • Testing macros: SwiftSyntax test support & Point-Free’s swift-macro-testing (SwiftLee)
  • Compile-time URL validation example (blog) (Vodafone tech)
  • Swift 6.2 release notes (current stable) (Swift.org)