Llevarse las ideas: efectos como datos, navegación como estado y testing exhaustivo en cualquier lenguaje
Lo que sobrevive a un framework no es su API sino sus invariantes, y esta lección separa quirúrgicamente unos de otra. Primero destila qué de TCA es esencia transferible y qué es acomodo a Swift. Después traduce las tres ideas centrales a TypeScript y a Kotlin: el efecto como valor interpretado por un runtime, el destino de navegación como dato del estado y el test que falla si queda trabajo sin consumir. Y termina con la advertencia que casi nadie hace: en un lenguaje sin exhaustividad ni semántica de valor, esas garantías no se heredan, se compran, y hay que saber con qué se pagan.
Las arquitecturas caducan, los frameworks se abandonan y las APIs que hoy dominas serán dentro de una década una nota al pie que confunde a quien lea código antiguo. Lo que no caduca son los invariantes, porque no son invenciones de nadie sino observaciones sobre cómo se comporta el estado cuando se le deja hacer lo que quiere. TCA es una encarnación particularmente rigurosa de tres de ellos, y esta lección es el ejercicio de extraerlos de su envoltorio de Swift para dejarlos utilizables en un proyecto de TypeScript, de Kotlin o de lo que venga después. El ejercicio tiene, además, un efecto secundario útil: al ver qué se puede llevar y qué no, se entiende por fin qué parte del coste de TCA era esencial y qué parte era el precio de hacerlo en este lenguaje y en esta plataforma.
- Separar la esencia transferible de TCA de sus acomodos a Swift, a SwiftUI y a las macros.
- Implementar el efecto como dato con su intérprete en un lenguaje sin
Effect. - Modelar la navegación como estado en la web y en Android, y reconocer cuándo la plataforma ya te lo daba.
- Reproducir el testing exhaustivo fuera de Swift y saber qué garantías hay que comprar y a qué precio.
Qué es esencia y qué es Swift
No todo lo que has aprendido viaja. Conviene hacer el corte con precisión, porque quien intenta reproducir TCA en otro lenguaje copiando su superficie acaba con toda la ceremonia y ninguna de las garantías, que es el peor resultado posible. La columna de la izquierda es lo que existía antes de Swift y existirá después; la de la derecha es cómo Swift consiguió que resultara ergonómico.
| Esencia transferible | Acomodo a Swift |
|---|---|
| Flujo unidireccional con una sola escritura del estado | El inout del reducer y la copia con COW |
| El estado como valor completo y comparable | struct, Equatable y la semántica de valor del lenguaje |
| Los eventos enumerados y exhaustivos | El enum y el switch que el compilador exige cerrar |
| El efecto como dato que un runtime interpreta | Effect con .run, .merge y .cancellable |
| Las dependencias como valores del ámbito | @Dependency y el almacenamiento por tarea |
| La composición por proyecciones del estado y del evento | Key paths, case paths y @CasePathable |
| La aserción exhaustiva del cambio y del trabajo pendiente | TestStore y su diagnóstico con custom-dump |
La lectura honesta de la tabla es que la columna derecha no es decorativa: sin exhaustividad del compilador, la enumeración de eventos es una convención que alguien romperá; sin semántica de valor, comparar dos estados es caro o incorrecto. Trasladar la izquierda a un lenguaje que no regala la derecha es posible, pero hay que reponer cada garantía con una herramienta explícita, y esa reposición es el verdadero trabajo de portar la idea.
Una sola escritura
Solo un lugar del sistema muta el estado, y recibe como argumento todo lo que necesita saber para decidir.
El trabajo, descrito
Lo que tarda no se ejecuta donde se decide: se describe, se devuelve y lo interpreta un runtime.
El destino, guardado
Dónde está el usuario es un dato del estado, así que se serializa, se restaura y se prueba como cualquier otro.
Nada sin declarar
El test afirma el cambio completo y no permite terminar con trabajo pendiente que nadie miró.
El efecto como dato, en cualquier lenguaje
La idea más portátil de las tres, y la que más rinde de inmediato, es que una función que decide no debe además ejecutar. Se separa en dos: una función pura que devuelve una descripción del trabajo, y un intérprete que sabe ejecutar esas descripciones y devolver sus resultados al ciclo. En Swift eso se llama Effect; en cualquier otro lenguaje son cuarenta líneas de unión discriminada más un bucle.
type Effect<A> =
| { tag: 'none' }
| { tag: 'run'; id?: string; work: (send: (a: A) => void, signal: AbortSignal) => Promise<void> }
| { tag: 'merge'; effects: Effect<A>[] }
type Reducer<S, A> = (state: S, action: A) => [S, Effect<A>]
function interpret<A>(effect: Effect<A>, send: (a: A) => void, live: Map<string, AbortController>) {
switch (effect.tag) {
case 'none': return
case 'merge': return effect.effects.forEach(e => interpret(e, send, live))
case 'run': {
if (effect.id) live.get(effect.id)?.abort() // cancelacion por id
const ctrl = new AbortController()
if (effect.id) live.set(effect.id, ctrl)
void effect.work(send, ctrl.signal)
}
}
}
Que la cancelación por identificador aparezca en la línea cuarta del intérprete y no en el reducer no es casualidad: es la misma división que en TCA. El reducer solo dice quiero este trabajo y se llama así; quién lo aborta, cuándo y con qué mecanismo del sistema operativo es asunto del intérprete. La versión de Kotlin es literalmente el mismo diseño con otra sintaxis y con corrutinas donde había Task.
sealed interface Efecto<out A> {
data object Ninguno : Efecto<Nothing>
data class Ejecutar<A>(val id: String? = null, val trabajo: suspend (suspend (A) -> Unit) -> Unit) : Efecto<A>
data class Fusionar<A>(val efectos: List<Efecto<A>>) : Efecto<A>
}
fun interface Reductor<S, A> { fun reducir(estado: S, accion: A): Pair<S, Efecto<A>> }
Merece la pena notar lo que estas cuarenta líneas te compran en un proyecto que no tiene nada: a partir de aquí, la lógica de una pantalla es una función que se llama en un test sin instanciar la pantalla, y la asincronía deja de ser algo que ocurre para pasar a ser algo que se devuelve. Es, con diferencia, la mejor relación entre líneas escritas y bugs evitados de todo el track, y es la que se puede introducir en una base de código ajena sin pedirle permiso a nadie.
Como todo el trabajo del sistema pasa por una sola función, el intérprete es el punto natural para registrar, medir y reproducir. Añadir ahí una traza de cada efecto lanzado, su identificador, su duración y la acción que devolvió te da, gratis, un registro de ejecución que se puede reproducir paso a paso. Es el mismo lugar donde en TCA vive _printChanges, y la razón por la que las herramientas de depuración de las arquitecturas unidireccionales son tan buenas: no hay que instrumentar cien sitios, solo uno.
La navegación como estado, fuera de Apple
En la web esta idea provoca una sonrisa incómoda, porque la plataforma la lleva regalando desde 1991: la URL es estado serializado, y el botón de atrás es una máquina de estados con historial. Lo que ha ocurrido durante veinte años es que las aplicaciones fueron colocando por encima un segundo estado de navegación, en modales gestionados con booleanos y menús con banderas, que no está en la URL y por tanto no se puede compartir, ni restaurar, ni probar. Modelar la navegación como dato en la web no es importar una idea de iOS: es dejar de pelearse con la que ya había.
// Un unico destino posible, como en un enum de Swift
type Destino =
| { tag: 'ninguno' }
| { tag: 'detalle'; id: string }
| { tag: 'editor'; borrador: Borrador }
| { tag: 'confirmarBorrado'; id: string }
type AppState = { filas: Fila[]; destino: Destino }
// La URL es una proyeccion del estado, no una fuente paralela de verdad
const aURL = (d: Destino) => d.tag === 'detalle' ? `/items/${d.id}` : '/'
En Android el argumento es el mismo con otro vocabulario. El estado inmutable de MVI ya contiene la pantalla actual, y la biblioteca de navegación se limita a renderizarla; cuando en su lugar se emite un efecto de una sola vez del tipo navega a detalle, aparecen exactamente los mismos bugs que TCA elimina: navegación duplicada al girar el dispositivo, destinos perdidos al restaurar el proceso y pruebas que necesitan un emulador. La regla, en cualquier plataforma, se enuncia igual de corta: si al reconstruir la app desde cero con el estado guardado no vuelves a la misma pantalla, la navegación no era estado.
flowchart LR I1[Efectos como datos] --> S1[Swift: Effect y Store] I1 --> T1[TypeScript: union e interprete] I1 --> K1[Kotlin: sealed y corrutinas] I2[Navegacion como estado] --> S2[Swift: Presents y StackState] I2 --> T2[Web: la URL de siempre] I2 --> K2[Android: estado de MVI] I3[Testing exhaustivo] --> S3[Swift: TestStore] I3 --> T3[TypeScript: store de pruebas] I3 --> K3[Kotlin: orbit-test y turbine] style I1 fill:#89b4fa,color:#11111b style I2 fill:#a6e3a1,color:#11111b style I3 fill:#fab387,color:#11111b
Testing exhaustivo: la garantía que hay que comprar
La tercera idea es la que peor viaja sola y la que más recompensa cuando se hace bien. El TestStore no es un ayudante de aserciones: es un objeto que impone tres obligaciones simultáneas —declarar el cambio de estado completo, declarar cada efecto recibido y no dejar ninguno pendiente al terminar— y falla si incumples cualquiera. Reproducir eso fuera de Swift es sencillo de escribir y difícil de sostener, porque nada en el lenguaje te obliga a usarlo.
function testStore<S, A>(reducer: Reducer<S, A>, initial: S) {
let state = structuredClone(initial)
const pendientes: A[] = []
return {
send(action: A, cambio: (s: S) => void) {
const esperado = structuredClone(state); cambio(esperado)
const [siguiente, efecto] = reducer(state, action)
assertDeepEqual(siguiente, esperado) // 1. el cambio, completo
state = siguiente
interpret(efecto, a => pendientes.push(a), new Map())
},
finish() {
if (pendientes.length) throw new Error('Efectos sin consumir') // 2. nada pendiente
},
}
}
Las tres garantías se compran, en un lenguaje sin exhaustividad, con tres herramientas distintas: uniones discriminadas más un switch con caso imposible verificado por tipos para cerrar los eventos, una biblioteca de inmutabilidad o una convención de congelación para que el estado no se mute a espaldas del reducer, y una comparación estructural con diferencia legible para que el fallo diga qué campo cambió y no solo que dos objetos difieren. Sin esas tres compras tendrás la forma de TCA sin ninguna de sus promesas, y el equipo concluirá —con razón— que la ceremonia no compensa.
Vale la pena situar lo que has aprendido en una historia más larga que la de una biblioteca de Swift, porque solo así se entiende por qué se transfiere tan bien. El efecto como dato es el patrón del intérprete llevado al límite y es, en el fondo, la misma jugada que las mónadas libres, los sistemas de actores de Erlang y las colas de mensajes: describir el trabajo en una estructura que otro componente ejecuta, para que la descripción se pueda inspeccionar, encolar, reintentar, registrar o descartar. La navegación como estado es una instancia particular del event sourcing y de la separación entre mandato y proyección: si el hecho está guardado, la vista es una función de él y se puede reconstruir. Y el testing exhaustivo es una manifestación práctica de la totalidad de funciones, la vieja disciplina de considerar todos los casos del dominio en lugar de los que se te ocurrieron el martes. Ninguna de las tres se inventó en Swift, y las tres llevan décadas apareciendo y desapareciendo bajo nombres distintos, lo cual debería servirte de advertencia contra el entusiasmo de framework: si TCA se abandonara mañana, no habrías perdido nada importante, porque lo que aprendiste no era TCA. Pero hay un corolario menos cómodo y más útil, y es el que quiero que te lleves de esta lección por encima de cualquier fragmento de código. Estas ideas no son gratis en ningún sitio, y en Swift son baratas por razones muy concretas que otro lenguaje puede no darte: exhaustividad de switch, semántica de valor con copia diferida, tipos suma de primera clase y un compilador dispuesto a rechazar programas incompletos. Cuando las trasplantes, tu primer trabajo no es reproducir la API sino inventariar qué garantías dejaste atrás y decidir, una por una, con qué las repones o si aceptas perderlas. Un equipo que hace ese inventario acaba con una versión local de la arquitectura que le sirve durante años; uno que copia la forma sin el inventario acaba con más ceremonia que antes, los mismos bugs y una convicción duradera de que todo esto era una moda. La diferencia entre ambos resultados no está en el talento ni en el lenguaje: está en haber entendido que una arquitectura no es un conjunto de tipos, sino un conjunto de cosas que el sistema te impide hacer.
- Escribe en TypeScript o en Kotlin el tipo del efecto, la firma del reducer y el intérprete con cancelación por identificador. No pases de sesenta líneas.
- Modela una pantalla real con un destino como unión discriminada y comprueba que el estado dos modales a la vez no se puede construir. Si se puede, tu unión no era exclusiva.
- Implementa el store de pruebas con las tres obligaciones y escribe un test que falle por cada una de ellas por separado, para verificar que la red está bien tejida.
- Haz el inventario: enumera qué garantías te daba el compilador de Swift que aquí no tienes, y decide para cada una si la repones con tipos, con una biblioteca o con revisión de código.
- Toma una pantalla existente de ese proyecto y reescríbela con tu núcleo. Compara líneas, bugs conocidos y tiempo de escritura del primer test, y escribe la conclusión aunque sea desfavorable.