Swift Macros en Swift 6 e iOS 26: Guía Práctica desde Cero con SwiftSyntax

Guía práctica de Swift Macros en Swift 6 e iOS 26: freestanding, attached, body macros, testing con assertMacroExpansion y ejemplos reales con SwiftSyntax.

Swift Macros iOS 26: Guía Práctica 2026

Actualizado: 28 de agosto de 2026

Un Swift Macro es una transformación de código fuente a código fuente que el compilador de Swift ejecuta en tiempo de compilación para expandir una anotación (# o @) en declaraciones o expresiones reales, con seguridad de tipos y sin impacto en el ABI. Los Swift Macros llegaron en Swift 5.9 y, ya en Swift 6, se han convertido en el mecanismo con el que Apple implementa @Observable en SwiftUI, @Model en SwiftData y #expect en Swift Testing. Si has usado cualquiera de esos atributos, ya has usado macros (probablemente sin ver la maquinaria detrás). Esta guía la enseña entera.

  • Los macros son código Swift que corre en un plugin externo al compilador y devuelve nodos de SwiftSyntax (AST) que se insertan en el código original.
  • Existen dos familias: freestanding (invocadas con #) y attached (aplicadas como @ sobre una declaración), con siete roles combinables entre ellas.
  • Swift 6 añadió el rol @attached(body), que permite sintetizar o envolver el cuerpo de una función completa, algo imposible en Swift 5.9.
  • Un paquete de macros mínimo se estructura en tres targets: la librería con las declaraciones, el plugin con las implementaciones y los tests con assertMacroExpansion.
  • Los macros nunca deben ocultar comportamiento sorprendente: la regla es que el código expandido debe leerse tan bien como el código escrito a mano, porque el desarrollador lo abrirá en Xcode con "Expand Macro".

¿Qué son los Swift Macros y por qué existen?

Un macro es un plugin de compilador escrito en Swift puro que recibe un fragmento de tu programa en forma de árbol de sintaxis abstracta (AST) y devuelve más árbol. El compilador toma esa salida, la inserta en el mismo punto donde apareció la anotación y sigue compilando como si tú hubieses escrito ese código a mano. No hay reflection, no hay runtime extra, no hay ABI que romper. Todo pasa en la fase de type-check, así que un macro mal escrito falla la compilación en lugar de fallar la app.

Honestamente, vengo del mundo de Objective-C y viví la era en que el #define del preprocesador de C era lo único parecido a metaprogramación segura: no entendía tipos, no reportaba errores útiles y ampliaba texto crudo. Los Swift Macros son la antítesis exacta de aquello. Trabajan sobre SwiftSyntax (la misma librería que usa Xcode para colorear tu código), así que ven cada nodo con su tipo, su posición y sus atributos. Cuando un macro se equivoca, marca la línea exacta con un diagnostic nativo que aparece en el editor como cualquier otro error del compilador.

Este es el motivo por el que Apple está migrando funciones del propio lenguaje a macros: @Observable, que reemplaza a ObservableObject, es un macro; @Model de SwiftData es un macro; #expect y #require de Swift Testing son macros. Si estás pensando en cómo migrar de ObservableObject, revisa nuestra guía completa del Observation Framework. Internamente, ese @Observable genera exactamente el tipo de accessors que aprenderás a escribir en este artículo.

Diferencia entre macros freestanding y attached

Swift tiene dos familias de macros y la distinción es puramente sintáctica: cómo invocas el macro determina qué puede hacer. Los freestanding macros se invocan con almohadilla (#macroName(args)) y sustituyen su punto de invocación por otra cosa. Los attached macros se aplican como atributo (@macroName) sobre una declaración existente y añaden o modifican esa declaración sin borrarla.

Un freestanding es un reemplazo: escribes let miURL = #URL("https://apple.com") y el macro sustituye la expresión completa por URL(string: "https://apple.com")!, validando en compilación que la cadena es una URL correcta. Un attached, en cambio, es una adición: escribes @AutoCodable struct Usuario { let id: UUID; let nombre: String } y el macro deja la struct intacta pero genera la conformidad a Codable y sus CodingKeys como declaraciones nuevas dentro del mismo tipo.

Un macro puede combinar varios roles attached (por ejemplo, ser member y extension al mismo tiempo), pero no puede combinar varios roles freestanding en la misma declaración: o produce una expresión, o produce declaraciones, nunca las dos. Esta restricción existe porque el compilador necesita saber en qué contexto sintáctico va a insertar el resultado antes de invocar tu plugin.

Los siete roles de macros (y el nuevo body de Swift 6)

Cada rol implementa un protocolo distinto de SwiftSyntax y recibe distinta información. Esta es la tabla de referencia que tengo pegada al monitor:

RolFamiliaProtocoloQué produce
@freestanding(expression)FreestandingExpressionMacroUna expresión que devuelve un valor
@freestanding(declaration)FreestandingDeclarationMacroUna o varias declaraciones sueltas
@attached(peer)AttachedPeerMacroDeclaraciones hermanas junto a la anotada
@attached(accessor)AttachedAccessorMacroAccessors get/set/willSet en una propiedad
@attached(memberAttribute)AttachedMemberAttributeMacroAtributos añadidos a los miembros del tipo
@attached(member)AttachedMemberMacroMiembros nuevos dentro del tipo
@attached(extension)AttachedExtensionMacroUna extension del tipo, con conformidades
@attached(body)Attached (Swift 6+)BodyMacroEl cuerpo de una función completo

El truco práctico está en las combinaciones. @Observable combina memberAttribute (para poner @ObservationTracked en cada propiedad almacenada), member (para añadir un registrar de observaciones) y extension (para declarar la conformidad al protocolo Observable). El macro se declara una sola vez pero produce código en tres lugares distintos del tipo, coordinado por el compilador para que los nombres no colisionen.

El rol body, introducido en Swift 6, era el gran hueco del sistema. Permite envolver el cuerpo de una función para añadir logging, mediciones o transacciones sin tocar la firma. Es la base sobre la que se están reimplementando patrones que antes obligaban a method swizzling en Objective-C.

Estructura de un paquete de macros paso a paso

Un macro vive siempre dentro de un Swift Package porque necesita compilarse como plugin ejecutable independiente del código que lo consume. Cuando ejecutas swift package init --type macro obtienes tres targets orquestados así:

  1. Librería (MiMacro): contiene únicamente las declaraciones públicas de los macros, es decir, el @freestanding o @attached con su firma. Es lo que importan los usuarios.
  2. Plugin (MiMacroMacros): un ejecutable que enlaza SwiftSyntax y contiene la implementación, con las clases que conforman ExpressionMacro, MemberMacro, etc. El compilador lo lanza como proceso hijo cuando encuentra una invocación.
  3. Tests (MiMacroTests): usa SwiftSyntaxMacrosTestSupport y assertMacroExpansion para verificar la salida del plugin sin necesidad de tener un consumidor.

El Package.swift tiene la peculiaridad de declarar el target del plugin con .macro, no con .executableTarget. Este es el esqueleto mínimo:

// Package.swift
// swift-tools-version: 6.0
import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MiMacro",
    platforms: [.macOS(.v13), .iOS(.v17)],
    products: [
        .library(name: "MiMacro", targets: ["MiMacro"]),
    ],
    dependencies: [
        .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"),
    ],
    targets: [
        .macro(
            name: "MiMacroMacros",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        .target(name: "MiMacro", dependencies: ["MiMacroMacros"]),
        .testTarget(
            name: "MiMacroTests",
            dependencies: [
                "MiMacroMacros",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),
    ]
)

Tu primer macro: #URL con validación en compilación

El caso más didáctico es un macro que sustituye una llamada #URL("...") por un URL(string:)! validado en compilación. Si la cadena no es una URL correcta, el macro falla la build en lugar de dejarte un fatalError en producción.

En la librería declaramos la firma:

// MiMacro/URLMacro.swift
@freestanding(expression)
public macro URL(_ string: String) -> URL = #externalMacro(
    module: "MiMacroMacros",
    type: "URLMacro"
)

En el plugin implementamos la expansión. Recibimos el nodo node con toda la información sintáctica y devolvemos una ExprSyntax:

// MiMacroMacros/URLMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros
import Foundation

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.arguments.first?.expression,
              let literal = argument.as(StringLiteralExprSyntax.self),
              let value = literal.representedLiteralValue,
              URL(string: value) != nil
        else {
            throw MacroError.invalidURL
        }
        return "URL(string: \(argument))!"
    }
}

enum MacroError: Error, CustomStringConvertible {
    case invalidURL
    var description: String {
        switch self {
        case .invalidURL: return "El argumento no es una URL válida en compilación."
        }
    }
}

El detalle que suele confundir: la interpolación "URL(string: \(argument))!" no es una cadena, es un literal de ExprSyntax. SwiftSyntax parsea la cadena y devuelve un nodo del AST, así que si escribes sintaxis inválida el error aparece en tu plugin, no en el usuario.

Macro attached en acción: @AutoCodable

Los macros attached son donde de verdad se eliminan cientos de líneas de boilerplate. Vamos a hacer un @AutoCodable que genera CodingKeys convertidas a snake_case automáticamente (el patrón más común en APIs REST, y algo que en Swift puro requiere escribir manualmente el enum cada vez).

// MiMacro/AutoCodable.swift
@attached(member, names: named(CodingKeys))
@attached(extension, conformances: Codable)
public macro AutoCodable() = #externalMacro(
    module: "MiMacroMacros",
    type: "AutoCodableMacro"
)

Fíjate en dos cosas: declaramos los nombres que va a introducir el rol member (CodingKeys) y las conformances que va a añadir el rol extension (Codable). Sin estas listas el compilador no puede resolver los nombres antes de expandir el macro, y falla con un error confuso.

// MiMacroMacros/AutoCodableMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros

public struct AutoCodableMacro: MemberMacro, ExtensionMacro {

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        let properties = declaration.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            .flatMap { $0.bindings }
            .compactMap { $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text }

        let cases = properties
            .map { "    case \($0) = \"\(snakeCase($0))\"" }
            .joined(separator: "\n")

        let codingKeysDecl: DeclSyntax = """
        enum CodingKeys: String, CodingKey {
        \(raw: cases)
        }
        """
        return [codingKeysDecl]
    }

    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        let ext: DeclSyntax = "extension \(type.trimmed): Codable {}"
        return [ext.cast(ExtensionDeclSyntax.self)]
    }

    private static func snakeCase(_ camel: String) -> String {
        var result = ""
        for (index, character) in camel.enumerated() {
            if character.isUppercase && index > 0 { result.append("_") }
            result.append(character.lowercased())
        }
        return result
    }
}

Aplicado sobre @AutoCodable struct Usuario { let idInterno: Int; let nombreCompleto: String }, el macro sintetiza automáticamente el CodingKeys con id_interno y nombre_completo, más la extension Usuario: Codable {}. Si sigues la guía práctica de SwiftData, notarás que @Model usa exactamente este patrón para inyectar accessors observables sin que escribas una línea de boilerplate.

Body macros: envolver funciones enteras en Swift 6

El rol @attached(body), disponible desde Swift 6, permite reescribir el cuerpo entero de una función manteniendo su firma. Es la solución nativa a un problema histórico: cómo instrumentar funciones sin recurrir a swizzling ni a wrappers explícitos. Un caso típico es @Traced, que añade OSLog automático de entrada, salida y duración:

// MiMacro/Traced.swift
@attached(body)
public macro Traced() = #externalMacro(
    module: "MiMacroMacros",
    type: "TracedMacro"
)

// Uso en el consumidor
@Traced
func procesarPedido(id: UUID) async throws -> Pedido {
    try await repositorio.buscar(id)
}

La implementación recibe el cuerpo original y devuelve un nuevo bloque. La regla de oro es preservar el flujo: cualquier return del cuerpo original tiene que seguir funcionando.

public struct TracedMacro: BodyMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingBodyFor declaration: some DeclSyntaxProtocol
                                     & WithOptionalCodeBlockSyntax,
        in context: some MacroExpansionContext
    ) throws -> [CodeBlockItemSyntax] {
        guard let originalBody = declaration.body else { return [] }
        let statements = originalBody.statements
        return [
            "let __inicio = ContinuousClock.now",
            "defer { print(\"trace: \\(ContinuousClock.now - __inicio)\") }",
        ] + Array(statements)
    }
}

Este patrón encaja de forma natural con la concurrencia estructurada de Swift 6.2: el defer se ejecuta incluso cuando la tarea es cancelada, así que la traza siempre se emite. En mi experiencia, el body macro reemplaza cerca del 90 % de los casos donde antes recurría a subclases o a decoradores manuales.

Cómo probar macros con assertMacroExpansion

Un macro es código que produce código, así que las pruebas comparan cadenas: tú das una entrada, declaras la salida esperada, y assertMacroExpansion falla si difieren en un solo carácter (respetando indentación). Este es el patrón:

import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import MiMacroMacros

final class URLMacroTests: XCTestCase {

    let macros: [String: any Macro.Type] = ["URL": URLMacro.self]

    func testExpansionValida() {
        assertMacroExpansion(
            #"let sitio = #URL("https://swiftcrafted.dev")"#,
            expandedSource: #"let sitio = URL(string: "https://swiftcrafted.dev")!"#,
            macros: macros
        )
    }

    func testCadenaInvalidaFalla() {
        assertMacroExpansion(
            #"let sitio = #URL(" no es url ")"#,
            expandedSource: #"let sitio = #URL(" no es url ")"#,
            diagnostics: [
                DiagnosticSpec(
                    message: "El argumento no es una URL válida en compilación.",
                    line: 1, column: 13
                )
            ],
            macros: macros
        )
    }
}

Los tests corren sin compilador. SwiftSyntax parsea la entrada, invoca tu plugin directamente y compara la salida. Son extremadamente rápidos y son la única forma sensata de iterar: intentar depurar un macro compilando un consumidor real es doloroso porque los errores se muestran en el punto de invocación, no en tu plugin. Si vienes de Swift Testing en Xcode 26, puedes reescribir estos XCTestCase con @Test y #expect siguiendo el mismo patrón (el propio #expect es, recordemos, un macro).

Diagnósticos y mensajes de error claros

Un macro que lanza throw convierte el error en un diagnóstico rojo estándar del compilador, pero el mensaje se ancla al punto de invocación completo. Para señalar el argumento exacto que está mal, usa context.diagnose(...) con un Diagnostic específico:

context.diagnose(Diagnostic(
    node: Syntax(argument),
    message: SimpleDiagnosticMessage(
        message: "Este literal no es una URL válida",
        diagnosticID: MessageID(domain: "MiMacro", id: "invalidURL"),
        severity: .error
    ),
    fixIts: [FixIt(
        message: SimpleFixItMessage(message: "Reemplazar por about:blank"),
        changes: [.replace(oldNode: Syntax(argument),
                           newNode: Syntax("\"about:blank\"" as ExprSyntax))]
    )]
))

Un macro con fix-its aparece con el bombillo azul en Xcode y aplica el arreglo con un clic. Esta es la diferencia entre un macro que se siente parte del lenguaje y uno que se siente pegado con cola. Consulta la documentación oficial de Apple sobre cómo aplicar macros para ver cómo se comportan estos diagnósticos dentro del flujo del compilador.

Cuándo usar (y cuándo no) un macro

Después de escribir varios macros en proyectos reales, mi regla es sencilla: los macros deben eliminar boilerplate estructural, no ocultar lógica de negocio. Son ideales para conformidades repetitivas (Equatable, Codable), para inyectar tracing/logging alrededor de funciones, para validaciones en compilación (URLs, expresiones regulares, formatos de fecha) y para adaptar APIs viejas (callbacks) a nuevas (async/await). Todos estos son casos donde el código expandido es predecible y palabra por palabra escribible a mano.

Un macro es una mala idea cuando el resultado depende de valores en runtime (los macros no ven runtime), cuando el propósito se puede resolver con generics o protocol extensions (siempre más simples que un plugin externo), o cuando el equipo va a resentir el hecho de tener que abrir "Expand Macro" para entender qué pasa. Un patrón que tres personas escriben cada semana justifica un macro; uno que aparece una vez, no.

El coste real está en el tiempo de compilación: cada macro es un proceso hijo del compilador. Un paquete con doscientos usos de un macro pesado añade segundos perceptibles a la build. Para código en la ruta crítica, mide antes y después. Puedes seguir la evolución del ecosistema en el repositorio de swift-syntax en GitHub y leer la propuesta original en el SE-0389 sobre macros attached.

Preguntas frecuentes

¿Qué versión de Swift necesito para usar macros?

Los macros aparecieron en Swift 5.9 (Xcode 15) para todos los roles freestanding y attached excepto body. El rol @attached(body) requiere Swift 6 o superior. Para desarrollo en 2026, apunta a Swift 6.2 con Xcode 26.

¿Los Swift Macros afectan al rendimiento en runtime?

No. Los macros son transformaciones puramente en compilación: el código expandido se compila junto al tuyo y no introduce indirección, reflection ni sobrecoste. El único impacto es en el tiempo de build, porque el compilador lanza el plugin como proceso hijo por cada invocación.

¿Cómo veo el código que genera un macro?

En Xcode, haz clic derecho sobre la anotación (por ejemplo, sobre @Observable) y elige "Expand Macro". Se abrirá una vista con el código sintetizado tal cual lo verá el compilador, incluyendo todos los roles combinados.

¿Puedo distribuir mis macros como paquete de Swift Package Manager?

Sí. El paquete se publica como cualquier otro, con la particularidad de que el target de macro se compila para la plataforma del host de desarrollo (macOS), no para iOS. Los consumidores importan la librería y el compilador resuelve el plugin automáticamente al detectar la primera invocación.

¿Cuál es la diferencia entre un macro y un property wrapper?

Un property wrapper genera un tipo envolvente en runtime que intercepta accesos a la propiedad. Un macro genera código en compilación que puede incluir accessors, miembros nuevos, extensiones y cuerpos de función, así que no está limitado a propiedades. De hecho, el proyecto GSoC 2026 propone reimplementar los property wrappers como macros para unificar ambos mecanismos.

Lukas Müller
Sobre el Autor Lukas Müller

iOS developer and Swift author since the Objective-C days. Spends his evenings on side projects and his mornings on SwiftUI internals.