wandres.dev
SWIFT PARA TCA · value types y macros

Las macros de Swift: qué generan y cómo inspeccionarlas

Desde Swift 5.9 el compilador puede ejecutar programas que escriben programas, y TCA fue de las primeras librerías en apoyarse a fondo en ello. Esta lección explica qué es exactamente una macro, en qué momento del pipeline actúa y qué garantías la separan de un preprocesador de texto, recorre los roles que usan Reducer, ObservableState y CasePathable, enseña a expandirlas y a depurarlas tanto desde Xcode como desde la línea de comandos, y sostiene que la aportación real de las macros no es escribir menos código sino hacer que el andamiaje inevitable sea generado, verificado e inspeccionable en lugar de copiado a mano.

⏱ 19 min

Durante años, cuando una arquitectura de Swift exigía andamiaje repetitivo, había tres salidas y ninguna buena: escribirlo a mano en cada feature, generarlo con un script externo que había que recordar ejecutar, o esquivarlo con reflexión en tiempo de ejecución, pagándolo en seguridad de tipos. Swift 5.9 abrió una cuarta puerta al permitir que el compilador ejecute, durante la propia compilación, programas que producen código fuente. TCA se subió a esa puerta antes que casi nadie, y por eso hoy una feature entera se declara con @Reducer sobre una struct y @ObservableState sobre su estado. La tentación es tratar esas anotaciones como magia y seguir adelante. Es un mal negocio: casi todos los errores desconcertantes que da TCA vienen de la expansión, y esa expansión se puede leer. Esta lección enseña qué escribe cada macro, por qué no puede mentirte, y cómo poner el código generado delante de los ojos.

🎯 Al terminar esta lección sabrás
  • Definir qué es una macro de Swift, en qué fase del compilador se ejecuta y qué garantías la distinguen de un preprocesador textual.
  • Reconocer los roles de macro que usa TCA y qué añade cada anotación a tu tipo.
  • Expandir e inspeccionar el código generado desde Xcode y desde la línea de comandos, y depurar dentro de él.
  • Valorar el coste real de las macros en tiempo de compilación y sus límites deliberados.

Qué es una macro y en qué momento actúa

Una macro de Swift es un programa aparte —un ejecutable que el gestor de paquetes compila antes que tu código— que recibe un fragmento de sintaxis ya analizado y devuelve más sintaxis. El compilador analiza tu archivo, encuentra la anotación, lanza el proceso de la macro pasándole el árbol sintáctico del tipo que anotaste, recibe declaraciones nuevas, las inserta y solo entonces comprueba tipos sobre el resultado completo.

Cuatro propiedades separan esto de un #define de C, y las cuatro importan. Primera: la macro opera sobre sintaxis analizada, no sobre texto, así que no puede producir algo que no sea Swift sintácticamente válido. Segunda: es aditiva, solo puede añadir declaraciones y accesores, jamás borrar ni reescribir lo que escribiste; el código que lees sigue siendo el código que hay. Tercera: es higiénica, sus nombres internos no colisionan con los tuyos, y todo lo que introduce en tu ámbito tiene que declararlo en su definición. Y cuarta: el resultado pasa por el verificador de tipos como cualquier otro código, de modo que una macro no puede colar algo que no compilaría si lo hubieras escrito tú.

// Declaración de una macro: la firma vive en tu módulo,
// la implementación vive en un plugin del compilador.
@attached(member, names: named(State), named(Action))
@attached(extension, conformances: Reducer)
public macro Reducer() = #externalMacro(
  module: "ComposableArchitectureMacros",
  type: "ReducerMacro"
)

Las macros se dividen en dos familias: las autónomas, que se invocan con almohadilla —#Preview, #externalMacro— y aparecen donde va una expresión o una declaración; y las adjuntas, que se escriben con arroba delante de una declaración y la amplían. Las adjuntas se clasifican por rol, y el rol determina qué puede añadir y dónde.

Rol Qué añade Ejemplo en TCA
member miembros dentro del tipo anotado los typealias de State y Action
memberAttribute atributos sobre miembros existentes @ObservationStateIgnored sobre propiedades
accessor un get y un set en una propiedad la observación de @ObservableState
peer declaraciones hermanas junto a la anotada los casos generados de un enum de destinos
extension una extensión con conformidades la conformidad al protocolo Reducer

TCA los usa casi todos, y a menudo varios a la vez sobre la misma anotación.

Los roles que usa TCA

@Reducer es la más ambiciosa y combina varios roles a la vez. Sobre una struct de feature añade la conformidad al protocolo Reducer, sintetiza los typealias de State y Action a partir de tus tipos anidados, aplica @CasePathable y @dynamicMemberLookup al enum de acciones para habilitar los key paths de caso, envuelve tu body con @ReducerBuilder, y de paso emite diagnósticos propios cuando detecta errores frecuentes, como implementar body y reduce a la vez. Aplicada a un enum de destinos de navegación, hace algo aún más vistoso: genera los enums anidados State y Action con un caso por destino y su reducer compuesto.

@Reducer
struct Contador {
  @ObservableState
  struct State: Equatable { var cuenta = 0 }
  enum Action { case incrementar, decrementar }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementar: state.cuenta += 1; return .none
      case .decrementar: state.cuenta -= 1; return .none
      }
    }
  }
}

// Lo que la expansión añade, en esencia:
// extension Contador: ComposableArchitecture.Reducer {}
// @CasePathable @dynamicMemberLookup enum Action { ... }
// typealias State = ...  /  typealias Action = ...

@ObservableState juega en otro registro: usa el rol accessor para reescribir el acceso a cada propiedad almacenada del estado, de modo que leerla registre una dependencia de observación y escribirla notifique el cambio. Para ello introduce un registrador y un identificador internos y añade la conformidad al protocolo de observación.

@ObservableState
struct State: Equatable {
  var cuenta = 0
}

// Expansión, en esencia: cada propiedad deja de ser almacenada
// y pasa a mediar sus accesos por un registrador interno.
// var cuenta: Int {
//   get { _$observationRegistrar.access(self, keyPath: \.cuenta); return _cuenta }
//   set { _$observationRegistrar.mutate(self, keyPath: \.cuenta, &_cuenta, newValue) }
// }

Ese es el mecanismo que permite a SwiftUI redibujar solo las vistas que leyeron el campo que cambió, en lugar de invalidar la pantalla entera; sin macro tendrías que escribir a mano un par de accesores por propiedad, y bastaría olvidar uno para que una vista dejara de actualizarse sin ningún error visible.

@DependencyClient, de la librería hermana de dependencias, ilustra un tercer estilo de aportación. Aplicada a una struct de closures, sintetiza un inicializador con cada closure como argumento con valor por defecto y rellena las que nadie proporcione con implementaciones que reportan un fallo al ser llamadas. El resultado es que olvidar inyectar una dependencia en un test deja de producir un comportamiento silencioso y produce un mensaje que nombra la closure exacta que faltaba.

Poner la expansión delante de los ojos

Nada de esto es opaco, y tratarlo como opaco es el error que alarga las depuraciones. Hay tres vías para leer lo que la macro escribió.

En Xcode, pulsa con el botón derecho sobre la anotación y elige expandir la macro: se abre un panel con el código generado, y desde ahí puedes saltar a él como a cualquier otro archivo. Desde la línea de comandos, el compilador vuelca todas las expansiones en la salida de la compilación.

# Volcar el codigo que generan todas las macros del paquete
swift build -Xswiftc -dump-macro-expansions 2>&1 | tee expansiones.txt

# Aislar lo generado para una feature concreta
grep -n -A 30 "Contador" expansiones.txt

Guardar ese volcado tiene un uso que se agradece al actualizar: comparar el archivo de dos versiones de TCA muestra exactamente qué cambió en el andamiaje generado, que suele explicar avisos nuevos mucho mejor que las notas de la versión. Y en el depurador, el código expandido aparece como un búfer con nombre prefijado por @__swiftmacro_, en el que se puede entrar paso a paso y poner puntos de interrupción como en cualquier otro archivo, de modo que ni siquiera en ejecución el código generado es una caja cerrada.

💡
Lee la expansión cuando el error no encaje con tu código

Los mensajes más desconcertantes de TCA suelen referirse a declaraciones que tú nunca escribiste, y ahí la reacción correcta no es probar variantes al azar sino expandir la macro y leer. Dos ejemplos de manual: un error sobre Equatable en un tipo que sí lo declara casi siempre viene de una propiedad interna generada; y un aviso de que un caso de acción no está en el enum aparece cuando tu Action fue reescrita por @CasePathable. Diez segundos leyendo la expansión ahorran media tarde de conjeturas.

El coste, los límites y por qué son deliberados

Las macros no salen gratis. El plugin es un ejecutable que hay que compilar antes que tu módulo, se lanza como proceso separado y en Xcode corre dentro de una caja de arena, así que una compilación limpia paga ese arranque y, en un proyecto con muchas features anotadas, la expansión se nota en los tiempos. De ahí también el aviso que Xcode muestra la primera vez que añades un paquete con macros y te pide confirmar que confías en él: una macro es código de terceros que se ejecuta en tu máquina durante la compilación, y ese consentimiento explícito es la contrapartida razonable a semejante poder. La contrapartida es que ese trabajo ocurre una vez por compilación y no deja ni rastro en ejecución: el binario contiene el código expandido y nada más, sin reflexión ni indirecciones.

Hay además un coste ergonómico que conviene anticipar: los diagnósticos. Cuando un error se origina en código generado, el mensaje puede señalar una declaración que tú no escribiste, y en los primeros meses eso desorienta. La cura es la costumbre de la sección anterior —expandir antes de conjeturar— y una segunda precaución práctica: mantener el estado y las acciones tan simples como el dominio permita, porque cuanto más exótico es lo que anotas, más lejos cae el diagnóstico de la línea que lo provocó.

Los límites son igual de importantes. Una macro no puede modificar ni eliminar el código existente, no puede ver otros archivos de tu módulo —solo recibe la sintaxis de lo que anota, así que no puede razonar sobre tipos declarados en otra parte— y no puede saltarse la comprobación de tipos posterior. Estas restricciones parecen mezquinas hasta que las lees como lo que son: garantías. Un sistema donde el código generado pudiera reescribir el tuyo o depender de todo el módulo sería imposible de auditar y de reproducir. Aquí, en cambio, la relación entre lo que escribes y lo que se compila es una función determinista que puedes inspeccionar cuando quieras.

flowchart LR
SRC[tu codigo anotado] --> PARSE[analisis sintactico]
PARSE --> PLUG[plugin de macro en proceso aparte]
PLUG --> EXP[sintaxis generada solo aditiva]
EXP --> MERGE[fusion con tu arbol sintactico]
MERGE --> TC[verificacion de tipos del conjunto]
TC --> BIN[binario sin rastro de la macro]
style PLUG fill:#cba6f7,color:#11111b
style TC fill:#a6e3a1,color:#11111b
La macro no elimina la ceremonia: la traslada a un sitio donde puede ser verificada

Es tentador resumir las macros como una forma de escribir menos, y ese resumen se queda corto y además despista. El andamiaje que @Reducer genera no desaparece: sigue existiendo, se compila y se ejecuta; lo que cambia es su autoría y, con ella, su fiabilidad. Compara las alternativas históricas y se ve enseguida. El andamiaje escrito a mano diverge, porque cada copia envejece a su ritmo y nadie audita la número treinta y siete. El generado por un script externo se queda obsoleto en cuanto alguien olvida ejecutarlo, y encima ensucia el control de versiones con archivos que nadie lee pero todos revisan. El evitado con reflexión en ejecución cambia errores de compilación por cierres inesperados en producción, que es exactamente el intercambio contrario al que persigue TCA. La macro, en cambio, deriva el andamiaje de tu declaración cada vez que compilas, no puede quedarse desincronizada porque no tiene una vida propia que desincronizar, y su producto pasa por el mismo verificador de tipos que el resto. Hay una segunda lectura, más de fondo, que conviene llevarse de este nivel. Una arquitectura con garantías fuertes tiene un coste sintáctico inevitable: si quieres que el compilador demuestre que la composición encaja, que la observación es fina y que las acciones son direccionables, alguien tiene que escribir las conformidades, los key paths de caso y los accesores que hacen posibles esas demostraciones. Durante años ese peaje se pagó con las manos, y fue la razón principal por la que muchos equipos abandonaron TCA en la primera semana: la ceremonia era real y visible. Las macros no la abolieron —el sistema de tipos sigue necesitando lo mismo—, la reubicaron: la ceremonia es ahora una obligación de la máquina, y el humano se queda con las decisiones que sí requieren criterio, qué es estado, qué es acción, qué es dependencia. Por eso la actitud correcta ante una macro no es la fe ni la sospecha, sino la costumbre de expandirla: es código como el tuyo, escrito por un programa, y está a un clic de distancia.

⚔️ Expande, lee y reproduce a mano
  1. Coge una feature con @Reducer y @ObservableState y expande ambas macros en Xcode. Copia el resultado a un archivo aparte y léelo entero antes de seguir.
  2. Sobre esa expansión, señala qué produjo cada rol: qué miembros aparecieron, qué extensión con conformidad se añadió, qué accesores se inyectaron en las propiedades del estado.
  3. Repite el volcado desde la terminal con swift build -Xswiftc -dump-macro-expansions y compara ambas salidas. Guarda el volcado: te servirá para diferenciar versiones de TCA cuando actualices.
  4. Escribe a mano una feature equivalente sin macros: conformidad explícita, typealias explícitos y accesores de observación explícitos. Mide cuántas líneas te costó y cuáles de ellas habrías podido equivocar sin que el compilador te avisara.
  5. Provoca un error a propósito —por ejemplo, define body y reduce a la vez, o añade una propiedad no Equatable al estado— y comprueba si el diagnóstico apunta a tu código o al generado. Practica el reflejo de expandir antes de conjeturar.