Macros en Swift 5.9: @Expression, @Member, @Peer, @Extension y cómo crear las tuyas

Las macros de Swift 5.9 son una de las adiciones más potentes del lenguaje: permiten generar código Swift a partir de código Swift, como plugins del compilador que se ejecutan en tiempo de compilación. A diferencia de los templates de C o las anotaciones de Java, las macros de Swift son verificadas por el compilador, tipadas y depurables. Puedes ver el código que generan directamente en Xcode.

Qué son y qué resuelven

Una macro toma un fragmento de código Swift como entrada y produce código Swift nuevo como salida, todo en tiempo de compilación. El compilador expande la macro antes de compilar el resto del código, así que el código generado participa plenamente en la verificación de tipos y la detección de errores.

Los casos de uso principales son:

  • Eliminar boilerplate repetitivo (como @Observable o @Model en SwiftData)
  • Validar valores en tiempo de compilación
  • Generar implementaciones de protocolos automáticamente
  • Crear DSLs más expresivos

Tipos de macro

Swift distingue dos familias principales de macros:

Freestanding: se invocan con # y producen una expresión o declaración sin necesidad de estar adjuntas a ningún símbolo:

// Freestanding expression: produce un valor
let url = #URL("https://swift.org") // Error de compilación si la URL no es válida

// Freestanding declaration: genera declaraciones en el contexto actual
#warning("Esto está pendiente de revisar")

Attached: se invocan con @ y se adjuntan a una declaración existente para modificarla o añadir código a su alrededor:

@Observable          // Attached member + conformance: añade tracking a todas las props
class MiViewModel { ... }

@CaseIterable       // Attached conformance: genera allCases automáticamente
enum Color { case rojo, verde, azul }

@Testable           // Peer: genera un tipo auxiliar junto al marcado
struct Config { ... }

Roles de las macros attached

Las macros attached tienen roles específicos según qué código generan:

  • @attached(member): añade propiedades o métodos al tipo
  • @attached(memberAttribute): aplica atributos a los miembros existentes
  • @attached(accessor): añade get/set/willSet/didSet a una propiedad
  • @attached(peer): genera una declaración adicional junto a la marcada
  • @attached(conformance): hace que el tipo conforme a protocolos
  • @attached(extension): genera una extensión del tipo

Crear una macro: #URL validada en compilación

Un ejemplo clásico es una macro que valida URLs en tiempo de compilación, eliminando el optional y el riesgo de URLs inválidas en runtime:

// Declaración de la macro en el módulo principal
@freestanding(expression)
public macro URL(_ cadena: String) -> URL = #externalMacro(
    module: "MisMacrosImpl",
    type: "URLMacro"
)

// Implementación en el target de macros (MisMacrosImpl)
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 argumento = node.argumentList.first?.expression,
              let cadenaLiteral = argumento.as(StringLiteralExprSyntax.self),
              let valor = cadenaLiteral.representedLiteralValue else {
            throw MacroError.argumentoNoEsLiteral
        }

        guard URL(string: valor) != nil else {
            throw MacroError.urlInvalida(valor)
        }

        // El código generado es simplemente la URL forzada, segura porque
        // ya validamos en compilación
        return "URL(string: (argumento))!"
    }
}

// Uso — error de compilación si la URL no es válida:
let urlDocs = #URL("https://swift.org/documentation/")
let urlRota = #URL("esto no es una url") // ? Error en compilación

Macro attached: @Clamped para valores acotados

// Declaración
@attached(accessor)
public macro Clamped(min: Double, max: Double) = #externalMacro(
    module: "MisMacrosImpl",
    type: "ClampedMacro"
)

// Implementación
public struct ClampedMacro: AccessorMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax] {
        guard let propiedad = declaration.as(VariableDeclSyntax.self),
              let nombre = propiedad.bindings.first?.pattern else {
            throw MacroError.declaracionInvalida
        }

        let args = node.arguments?.as(LabeledExprListSyntax.self)
        let minVal = args?.first?.expression ?? "0"
        let maxVal = args?.dropFirst().first?.expression ?? "1"

        return [
            """
            get { _(nombre) }
            """,
            """
            set { _(nombre) = min(max(newValue, (minVal)), (maxVal)) }
            """
        ]
    }
}

// Uso
struct Configuracion {
    @Clamped(min: 0, max: 1)
    var volumen: Double = 0.5

    @Clamped(min: 8, max: 32)
    var tamanoFuente: Double = 14
}

Ver la expansión en Xcode

Una ventaja única de las macros de Swift es que puedes ver exactamente qué código generan. En Xcode, haz clic derecho sobre cualquier uso de una macro y selecciona "Expand Macro". Verás el código expandido inline, lo que facilita el debugging y la comprensión.

Property wrappers vs macros: cuándo usar cada uno

La pregunta natural es cuándo preferir un property wrapper a una macro adjunta de tipo accessor:

  • Property wrapper: cuando el comportamiento es en runtime, cuando necesitas que el wrapper sea un tipo de primera clase (pasable, almacenable), o cuando necesitas compatibilidad con iOS 13+
  • Macro @attached(accessor): cuando la lógica puede resolverse en compilación, cuando quieres validación en compilación, o cuando el código generado es demasiado complejo para un property wrapper elegante
// Property wrapper: estado que persiste entre invocaciones
@propertyWrapper
struct UserDefault {
    let clave: String
    let porDefecto: T
    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: clave) as? T ?? porDefecto }
        set { UserDefaults.standard.set(newValue, forKey: clave) }
    }
}

// Macro: validación en compilación que un property wrapper no puede hacer
@Clamped(min: 0, max: 100)
var progreso: Double = 50

Testing de macros

Las macros se testean con SwiftSyntaxMacrosTestSupport:

import XCTest
import SwiftSyntaxMacrosTestSupport

final class URLMacroTests: XCTestCase {
    func testURLValida() throws {
        assertMacroExpansion(
            """
            let url = #URL("https://swift.org")
            """,
            expandedSource: """
            let url = URL(string: "https://swift.org")!
            """,
            macros: ["URL": URLMacro.self]
        )
    }

    func testURLInvalida() throws {
        assertMacroExpansion(
            """
            #URL("no es una url")
            """,
            expandedSource: "",
            diagnostics: [
                DiagnosticSpec(message: "URL inválida: no es una url", line: 1, column: 1)
            ],
            macros: ["URL": URLMacro.self]
        )
    }
}

Resumen

Las macros de Swift 5.9 llenan el hueco entre los generadores de código externos (que no entienden Swift) y las técnicas de metaprogramación en runtime (que no dan errores hasta que la app arranca). Son el mecanismo con el que Apple implementa @Observable, @Model y otras APIs modernas, y que ya puedes usar en tu propio código para eliminar boilerplate, validar invariantes en compilación y crear APIs más expresivas.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP