Las macros llegaron a Swift con la versión 5.9 en septiembre de 2023, a través de la propuesta SE-0382. Son una de las adiciones más ambiciosas al lenguaje desde async/await: permiten generar código Swift en tiempo de compilación de forma segura, verificada por el compilador y depurable desde Xcode. Si has usado @Observable, #Preview o #expect en tus proyectos, ya has usado macros, aunque no lo hayas notado.
Qué problema resuelven las macros
Antes de las macros, la generación de código en Swift se hacía con herramientas externas (Sourcery, SwiftGen, scripts de build) que generaban ficheros .generated.swift que había que añadir manualmente al proyecto. Funcionaba, pero el código generado era opaco, difícil de depurar y dependía de herramientas fuera del compilador.
Las macros de Swift son distintas: el código generado es visible en Xcode (puedes hacer «expand macro» para ver qué ha generado), el compilador verifica el resultado como cualquier otro código Swift, y los errores dentro del código expandido se muestran en contexto.
Tipos de macros
Swift tiene cinco tipos de macros:
- Freestanding expression macros (
#nombre): se usan en posición de expresión y producen un valor. - Freestanding declaration macros (
#nombre): generan declaraciones en el nivel superior. - Attached member macros (
@Nombre): añaden miembros a un tipo. - Attached conformance macros (
@Nombre): añaden conformancias de protocolo. - Attached peer macros (
@Nombre): generan declaraciones adicionales junto al nodo al que se aplican.
Tu primera macro: una expression macro
Vamos a crear una macro #stringify que convierte una expresión en una tupla con el valor y su representación en texto:
// Declaración (en el módulo de la macro)
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(
module: "MisMacrosImpl",
type: "StringifyMacro"
)
// Implementación (en el target de implementación)
import SwiftSyntax
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let argumento = node.arguments.first?.expression else {
fatalError("La macro necesita un argumento")
}
return "((argumento), (literal: argumento.description))"
}
}
// Uso: let (resultado, descripcion) = #stringify(2 + 3) // resultado == 5, descripcion == "2 + 3"
Macros attached: @Observable como ejemplo
La macro @Observable (introducida en iOS 17 / Swift 5.9) es un ejemplo perfecto de macro attached que genera código real. Aplicada a una clase, genera automáticamente el código de observación que antes requería ObservableObject + @Published:
@Observable
class Contador {
var valor: Int = 0
}
// Lo que @Observable genera internamente (puedes verlo con "Expand macro" en Xcode):
class Contador {
var valor: Int = 0 {
get {
access(keyPath: .valor)
return _valor
}
set {
withMutation(keyPath: .valor) {
_valor = newValue
}
}
}
private var _valor: Int = 0
// ... más código de observación
}
Estructura de un paquete de macros
Las macros viven en su propio target de implementación y tienen una dependencia en SwiftSyntax. La estructura en Package.swift:
let package = Package(
name: "MisMacros",
dependencies: [
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0")
],
targets: [
// Interfaz pública de la macro
.macro(
name: "MisMacrosImpl",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
// Librería que expone las macros
.target(name: "MisMacros", dependencies: ["MisMacrosImpl"]),
// Tests
.testTarget(
name: "MisMacrosTests",
dependencies: [
"MisMacrosImpl",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
)
]
)
Tests de macros
SwiftSyntax incluye utilidades específicas para testear macros, verificando que la expansión produce exactamente el código esperado:
import SwiftSyntaxMacrosTestSupport
import XCTest
final class StringifyMacroTests: XCTestCase {
func testStringify() throws {
assertMacroExpansion(
"""
let x = #stringify(a + b)
""",
expandedSource: """
let x = (a + b, "a + b")
""",
macros: ["stringify": StringifyMacro.self]
)
}
}
Cuándo usar macros
Las macros tienen sentido cuando el código repetitivo sería incómodo de escribir a mano en cada tipo, cuando necesitas acceso al AST (árbol de sintaxis) para generar código condicionalmente, o cuando quieres proveer una API ergonómica sin sacrificar tipo estático.
No son la solución para todo: si lo que necesitas es lógica de runtime, los protocolos con extensiones siguen siendo la herramienta adecuada. Las macros son para generación de código en compilación, no para comportamiento dinámico. El diseño orientado a protocolos que describimos en el artículo sobre protocolos y genéricos y las macros son herramientas complementarias: una para abstracción en runtime, otra para generación en compilación.
Imagen: Pexels / Nemuel Sereti
