wandres.dev
SWIFT PARA TCA · value types y macros

Protocolos y genéricos: la composición con tipos que encajan

El protocolo Reducer no es una interfaz cualquiera: tiene tipos asociados, un Body que a su vez es un Reducer y un result builder que ensambla árboles de tipos en tiempo de compilación. Esta lección lee la declaración del protocolo línea a línea, explica por qué body devuelve un tipo opaco con some en vez de un existencial con any, muestra cómo los genéricos con restricciones hacen que Scope solo acepte el hijo correcto, y termina argumentando que la composición de TCA es un álgebra cuya clausura vigila el verificador de tipos, no la documentación.

⏱ 19 min

Cuando escribes var body: some ReducerOf<Self> estás usando, en once caracteres, tres de las piezas más sofisticadas del sistema de tipos de Swift: un protocolo con tipos asociados, un tipo opaco de retorno y un result builder. La mayoría de la gente las usa a diario sin mirarlas, igual que se usa some View en SwiftUI, y funciona. Pero en TCA conviene abrir la caja, porque la propiedad que hace valiosa a la arquitectura —que componer reducers devuelva otro reducer, indefinidamente y sin coste— no es una virtud del diseño de la librería, sino una consecuencia directa de cómo está declarado ese protocolo. Esta lección lee la declaración línea a línea y muestra qué garantiza cada palabra: por qué el hijo de un Scope no puede ser el equivocado, por qué body no borra el tipo de lo que construye, y por qué toda esa maquinaria se evapora en el binario final.

🎯 Al terminar esta lección sabrás
  • Leer la declaración del protocolo Reducer y explicar el papel de cada tipo asociado, incluido el Body recursivo.
  • Distinguir tipo opaco y existencial, y justificar por qué body devuelve some y no any.
  • Entender cómo los genéricos con restricciones hacen que Scope acepte solo el reducer hijo cuyo dominio encaja.
  • Ver el árbol de tipos que construye @ReducerBuilder y por qué esa estructura estática no cuesta nada en ejecución.

El protocolo Reducer y su Body recursivo

Despojada de documentación, la declaración cabe en unas pocas líneas y cada una está ahí por un motivo.

public protocol Reducer<State, Action> {
  associatedtype State
  associatedtype Action
  associatedtype Body: Reducer<State, Action>

  @ReducerBuilder<State, Action>
  var body: Body { get }

  func reduce(into state: inout State, action: Action) -> Effect<Action>
}

State y Action son tipos asociados: cada conformidad elige los suyos, de modo que un reducer no es un tipo sino una familia entera parametrizada por su dominio. Aparecen además entre corchetes angulares junto al nombre del protocolo porque son tipos asociados primarios, y eso permite escribir restricciones legibles como some Reducer<Contador.State, Contador.Action> sin recurrir a cláusulas where. El azúcar ReducerOf<Self> es exactamente eso: Reducer<Self.State, Self.Action>.

Los tipos asociados primarios cambian el día a día más de lo que parece, porque hacen que hablar de reducers ajenos deje de exigir cláusulas engorrosas. Una función genérica puede exigir el dominio exacto que necesita en la propia lista de parámetros.

public typealias ReducerOf<R: Reducer> = Reducer<R.State, R.Action>

// Una función que envuelve cualquier reducer del dominio de Contador:
func instrumentado<R: Reducer<Contador.State, Contador.Action>>(
  _ reducer: R
) -> some ReducerOf<Contador> {
  Reduce { state, action in
    print("acción:", action)
    return reducer.reduce(into: &state, action: action)
  }
}

La línea decisiva es la tercera. Body es otro tipo asociado, restringido a ser a su vez un Reducer sobre el mismo State y la misma Action. Ahí está codificada la clausura del álgebra: el cuerpo de un reducer es un reducer, así que anidar composiciones no produce nunca un tipo que se salga de la familia. Y ambos requisitos —body y reduce— traen implementación por defecto, cada una definida en términos de la otra: reduce delega en body y body delega en reduce. Debes proporcionar exactamente una; si no das ninguna, la recursión mutua es infinita y TCA lo detecta avisándote en ejecución en lugar de dejarte con una pila desbordada sin explicación.

some frente a any: el tipo se conserva, no se borra

Hay dos maneras de decir devuelvo algo que conforma a Reducer y no son intercambiables. any Reducer es un existencial: una caja que oculta el tipo concreto, lo guarda tras una tabla de testigos y despacha dinámicamente cada llamada. some Reducer es un tipo opaco: el tipo concreto sigue ahí, fijo y conocido por el compilador, y lo único que se oculta es su nombre a quien llama.

Aspecto some ReducerOf<Self> any Reducer
Tipo concreto conocido en compilación borrado en ejecución
Despacho estático, susceptible de inlining dinámico, a través de la tabla de testigos
Tipos asociados accesibles y comprobables inaccesibles sin apertura explícita
Coste ninguno, se especializa caja, indirección y posible asignación

La diferencia no es de rendimiento en primer lugar, sino de expresividad. Un existencial no te deja hablar de sus tipos asociados, así que un any Reducer no podría garantizar que su State es el mismo que el del padre en el que lo incrustas: la comprobación que hace segura la composición se volvería imposible de escribir. Con some, el compilador conoce el tipo entero de la composición —por gigantesco que sea— y puede verificar cada encaje y, después, especializar el código como si hubieras escrito la cadena a mano.

ℹ️
Por qué esto se parece tanto a SwiftUI

La simetría entre some ReducerOf<Self> y some View es deliberada hasta el último detalle: mismo patrón de protocolo con Body recursivo, mismo tipo opaco, mismo result builder. Point-Free lo eligió para que la intuición que ya tienes con vistas se transfiera intacta: igual que una View no es una pantalla sino una descripción de una jerarquía, un Reducer no es una ejecución sino una descripción de una composición, y el body de ambos declara de qué piezas está hecho.

Genéricos con restricciones: Scope no acepta al hijo equivocado

Los operadores de composición son struct genéricas cuyas restricciones expresan exactamente qué encaja con qué. Reducida a lo esencial, la de Scope dice esto:

public struct Scope<ParentState, ParentAction, Child: Reducer>: Reducer {
  let toChildState: WritableKeyPath<ParentState, Child.State>
  let toChildAction: CaseKeyPath<ParentAction, Child.Action>
  let child: Child

  public func reduce(
    into state: inout ParentState, action: ParentAction
  ) -> Effect<ParentAction> {
    guard let childAction = toChildAction.extract(from: action)
    else { return .none }
    return child
      .reduce(into: &state[keyPath: toChildState], action: childAction)
      .map(toChildAction.embed)
  }
}

Lee las dos primeras propiedades como lo que son: pruebas de compatibilidad. El key path de estado solo existe si ParentState tiene de verdad un campo del tipo Child.State; el key path de caso solo existe si ParentAction tiene de verdad un caso que envuelve Child.Action. Pasar el hijo equivocado no produce un fallo en ejecución ni un enrutado silencioso a ninguna parte: produce un error de compilación en la línea del Scope, porque el key path que le estás dando no se puede formar.

Y fíjate en el cuerpo, que resume el mecanismo entero de la composición en tres movimientos: extraer la acción del hijo del caso del padre, correr el hijo sobre la rebanada del estado del padre, y volver a envolver las acciones del efecto resultante en ese mismo caso. Bajar y subir por el mismo camino, con los tipos garantizando que el camino existe.

Los demás operadores siguen el mismo molde y solo cambian la forma del key path de estado, que es donde vive la cardinalidad del hijo. Esta es la firma de ifLet, simplificada para leerla de un vistazo:

extension Reducer {
  public func ifLet<ChildState, ChildAction, Child: Reducer>(
    _ toChildState: WritableKeyPath<State, ChildState?>,
    action toChildAction: CaseKeyPath<Action, ChildAction>,
    @ReducerBuilder<ChildState, ChildAction> then child: () -> Child
  ) -> some ReducerOf<Self>
  where Child.State == ChildState, Child.Action == ChildAction
}

Las dos cláusulas where son la comprobación entera: obligan a que el reducer que devuelve el constructor tenga por dominio exactamente el que describen los key paths. forEach repite el patrón con un key path hacia un IdentifiedArray y una acción que transporta el ID; ifCaseLet, con un key path de caso en lugar de un opcional. Tres operadores, un solo mecanismo, y en los tres el error de conectar mal se manifiesta como una restricción genérica insatisfecha y no como un enrutado que no dispara.

@ReducerBuilder: un árbol de tipos, no una lista en ejecución

Cuando listas varios reducers dentro de body sin comas, el result builder @ReducerBuilder recoge esa lista y la convierte en un único valor combinando los elementos por pares mediante tipos internos de secuencia. El tipo que resulta no es una colección homogénea: es un árbol.

var body: some ReducerOf<Self> {
  BindingReducer()
  Scope(state: \.contador, action: \.contador) { Contador() }
  Reduce { state, action in
    return .none
  }
  .ifLet(\.$destino, action: \.destino) { Destino() }
}

El tipo concreto de ese body es algo parecido a una secuencia anidada de BindingReducer, Scope y un Reduce envuelto en _IfLetReducer, todo ello escrito en el sistema de tipos y visible para el optimizador. Por eso ejecutar una acción no recorre un array de closures ni consulta ninguna tabla: es una cadena de llamadas conocidas estáticamente que el compilador puede aplanar. La composición, que en tiempo de escritura parece dinámica y flexible, en tiempo de ejecución es código plano.

flowchart TD
P[protocolo Reducer con State Action y Body] --> B[body devuelve some ReducerOf Self]
B --> RB[ReducerBuilder combina la lista]
RB --> T[arbol de tipos anidados]
T --> S[Scope comprueba encaje con key paths]
T --> IL[ifLet envuelve el reducer opcional]
T --> RD[Reduce hoja con la logica]
T --> OPT[el compilador especializa y aplana]
style P fill:#cba6f7,color:#11111b
style OPT fill:#a6e3a1,color:#11111b
La composición es un álgebra y el verificador de tipos es quien demuestra sus leyes

Lo que distingue a este diseño de la mayoría de arquitecturas no es que permita componer, sino dónde reside la garantía de que la composición es correcta. En un sistema basado en protocolos existenciales, en delegados o en objetos coordinadores, el encaje entre padre e hijo depende de que alguien conecte bien dos cabos: pasar el modelo adecuado, reenviar el evento al destinatario correcto, acordarse de desmontar lo que se montó. Esos contratos existen —están escritos en la documentación y en la cabeza del equipo—, pero no son comprobables, así que cada nueva conexión es una oportunidad de equivocarse que solo se descubre ejecutando. En TCA el mismo contrato está codificado en tipos: el Body recursivo garantiza que componer no se sale de la familia; los tipos asociados garantizan que un reducer trae consigo la identidad de su dominio; los key paths de estado y de caso garantizan que la rebanada del padre y la acción del hijo se corresponden; y el tipo opaco garantiza que nada de eso se pierde por el camino, porque el compilador conserva la estructura completa del árbol. La consecuencia es que la frase estos reducers componen deja de ser una afirmación de diseño y pasa a ser un teorema que la máquina verifica cada vez que compilas, con la misma seguridad con que verifica que no sumas un texto a un número. Y hay un segundo regalo, menos citado: como toda la estructura es estática, esta seguridad es gratis. No hay reflexión, ni búsqueda dinámica, ni cajas: la torre de genéricos que describe una app entera se derrumba en el optimizador y deja código directo. Es el trato que ofrece un sistema de tipos maduro cuando lo tomas en serio en lugar de rodearlo —más ceremonia al escribir, errores en compilación en lugar de bugs en producción, y ni un ciclo de reloj de peaje— y explica por qué Point-Free apostó por un protocolo con tipos asociados en vez de por una función suelta al estilo de Redux: la función habría sido más sencilla de leer el primer día y habría dejado toda la seguridad de la composición en manos de la disciplina humana.

⚔️ Hazle preguntas al sistema de tipos
  1. Escribe dos features minúsculas, Contador y Perfil, y compón la segunda dentro de un padre con Scope. Después cambia a propósito el key path de acción por el del otro hijo y lee con calma el error del compilador: identifica qué restricción genérica es la que no se satisface.
  2. Sustituye some ReducerOf<Self> por any Reducer en el body y anota todos los errores que aparecen. Explica cuál de ellos se debe a la imposibilidad de hablar de los tipos asociados de un existencial.
  3. Elimina a la vez body y reduce de un reducer, ejecuta y observa el aviso que emite TCA. Relaciónalo con las implementaciones por defecto que se llaman mutuamente.
  4. Añade una tercera hoja al body y usa la función de Xcode para revelar el tipo inferido de la expresión. Copia ese tipo en un comentario y comprueba que es un árbol anidado, no una lista.
  5. Escribe tu propio operador: un struct genérico que conforme a Reducer, guarde un reducer hijo con Child: Reducer y se limite a delegarle todo, imprimiendo antes la acción. Comprueba que puedes intercalarlo en cualquier body sin cambiar una sola línea del resto.