Manejar el fallo: convertir errores en acciones de respuesta
Un error que se escapa de un efecto es un error que tu dominio no ha nombrado. Esta lección recorre las tres formas de capturarlo —el manejador catch del constructor run, el do y catch interno, y el tipo Result dentro de la acción de respuesta—, explica por qué la cancelación no es un fallo y nunca llega al catch, por qué TaskResult quedó obsoleto en favor de Result con errores del dominio, y cómo diseñar el enum de fallos para que el compilador te obligue a decidir qué ocurre cuando el mundo dice que no.
En una arquitectura de flujo unidireccional, un error no es una excepción que sube por la pila hasta que alguien la atrapa: es un hecho del dominio, y como todo hecho del dominio tiene que llegar al reducer convertido en acción. El efecto es el único sitio donde puede fallar algo —porque es el único sitio donde ocurre algo—, así que también es el sitio donde el fallo debe traducirse. Un throws que se escapa de un efecto sin traducir es una pieza de información que tu aplicación ha perdido: nadie mutó el estado, nadie mostró nada, y el usuario se queda mirando un spinner eterno. Manejar el fallo en TCA es, sobre todo, un ejercicio de nomenclatura.
- Usar el manejador de fallo del constructor
.runy saber qué captura y qué no. - Explicar por qué la cancelación no es un fallo y nunca llega a ese manejador.
- Modelar la respuesta con
Resulty un error del dominio en vez de conany Error. - Diseñar el enum de fallos para que el compilador obligue a decidir en cada caso.
El manejador de fallo del efecto
El constructor .run admite un segundo parámetro, catch:, que recibe el error y el mismo send de la operación. Su trabajo es uno solo: traducir el error a una acción del dominio para que el reducer decida qué hacer con él.
case .actualizarPulsado:
state.cargando = true
return .run { send in
let articulos = try await cliente.cargarArticulos()
await send(.datosRecibidos(articulos))
} catch: { error, send in
await send(.falloDeCarga(error.localizedDescription))
}
Hay un detalle de diseño que conviene conocer al dedillo: la cancelación no es un fallo. Cuando el store cancela un efecto, la tarea lanza un CancellationError, y el constructor lo descarta en silencio sin llamar a tu manejador. Es lo correcto: que el usuario cambie de pantalla y una petición se aborte no es un error que mostrar, es el funcionamiento normal del ciclo de vida. Si el manejador se disparara ahí, cada navegación pintaría una alerta de fallo fantasma.
Si la closure de .run lanza un error que no es de cancelación y no has puesto manejador, TCA emite un aviso en tiempo de ejecución en compilaciones de depuración. No es un capricho pedagógico: significa que hay una rama de la realidad que tu dominio no contempla, y el síntoma en producción será silencio —el estado no cambia, el indicador de carga no se apaga—. Trata ese aviso como un error de compilación diferido.
Result en la acción: un caso para dos desenlaces
El manejador catch: es una red de seguridad de última instancia, y tiene una limitación estructural: cubre la closure entera, así que no sabe cuál de los tres await falló. Cuando cada fallo merece una respuesta distinta, la captura se hace dentro, junto al await que puede fallar, y el resultado viaja en un único caso de acción que lleva el desenlace completo.
enum Action {
case actualizarPulsado
case respuesta(Result<[Articulo], FalloDeCarga>)
}
case .actualizarPulsado:
state.cargando = true
return .run { send in
do {
await send(.respuesta(.success(try await cliente.cargarArticulos())))
} catch let fallo as FalloDeCarga {
await send(.respuesta(.failure(fallo)))
}
}
Un solo caso de acción para los dos desenlaces tiene una consecuencia muy práctica en el reducer: el switch obliga a escribir las dos ramas juntas, y ahí es donde se apaga el indicador de carga una sola vez, se guardan los datos o se guarda el mensaje. Con dos casos separados —uno de éxito y otro de fallo— la lógica común se duplica y tarde o temprano una de las copias se olvida de apagar el spinner.
case let .respuesta(resultado):
state.cargando = false
switch resultado {
case let .success(articulos):
state.articulos = articulos
state.fallo = nil
case let .failure(fallo):
state.fallo = fallo
}
return .none
La primera línea es la que justifica el diseño: state.cargando = false se escribe una vez y cubre los dos desenlaces, porque los dos desenlaces son el mismo hecho —la respuesta llegó— con contenido distinto. Modelar el resultado como una suma en lugar de como dos hechos independientes es lo que permite factorizar esa parte común sin acordarse de ella.
| Estrategia | Qué captura | Cuándo usarla |
|---|---|---|
Manejador catch: |
Cualquier error no cancelado de toda la closure | Red de seguridad para efectos con un solo punto de fallo |
do y catch internos |
El error de un await concreto |
Cuando cada fallo merece una acción distinta |
Result en la acción |
Éxito y fallo en un solo caso | Cuando hay una respuesta y quieres un solo punto de decisión |
Durante años TCA ofreció TaskResult por un motivo muy concreto: el TestStore exige acciones Equatable y el protocolo Error no lo es, así que TaskResult comparaba errores a base de conversiones dinámicas. Desde la versión 1.6 se desaconseja: la solución recomendada es usar el Result de la biblioteca estándar con un tipo de error propio que sí sea Equatable. El cambio no es cosmético; empuja hacia el diseño correcto, que es dejar de propagar any Error y empezar a nombrar los fallos que tu dominio reconoce.
Nombrar el fallo: el error como parte del dominio
Propagar any Error desde la red hasta la vista es la forma más rápida de acabar mostrando cadenas incomprensibles al usuario. El fallo se modela igual que cualquier otro dato del dominio: con un enum cerrado, Equatable, traducido en la frontera de la dependencia, donde todavía se sabe lo que significó cada código.
enum FalloDeCarga: Error, Equatable {
case sinConexion
case noAutorizado
case servidor(codigo: Int)
case respuestaIlegible
}
func cargarArticulos() async throws(FalloDeCarga) -> [Articulo]
Con los lanzamientos tipados de Swift 6 la firma declara exactamente qué puede fallar, y el compilador se vuelve tu revisor: al hacer switch sobre el Result en el reducer tendrás que decidir qué pasa en cada caso, y añadir mañana un .mantenimiento romperá la compilación en todos los sitios donde había que decidir. Eso es precisamente lo que quieres de un error importante.
flowchart LR W[Mundo exterior] -->|error crudo| D[Dependencia traduce] D -->|FalloDeCarga| E[Efecto run] E -->|accion respuesta| R[Reducer decide] R --> S[State con mensaje o reintento] style D fill:#f9e2af,color:#11111b style R fill:#cba6f7,color:#11111b
No todo fallo merece el mismo tratamiento, y esa es una decisión de producto que el reducer debe expresar caso por caso. Cuando el error es un enum del dominio, esas políticas son ramas legibles de un switch; cuando es any Error, son comparaciones de cadenas esperando a romperse.
Sin conexión
No es culpa de nadie y se resuelve solo. Guarda el estado de fallo, ofrece un botón de reintento y conserva los datos anteriores en pantalla en vez de vaciarlos.
No autorizado
No hay reintento posible sin intervención: la sesión caducó. La respuesta es navegar al acceso, y por tanto es un cambio de estado de navegación, no un mensaje.
Servidor caído
Es transitorio con alta probabilidad. Merece reintento automático con espera creciente dentro del propio efecto, y un límite claro tras el cual se rinde y avisa.
Respuesta ilegible
Es un fallo tuyo o de contrato, no del usuario. Registra el detalle para el equipo y muestra un mensaje genérico, porque el usuario no puede hacer nada al respecto.
El tercer caso ilustra bien que la política vive dentro del efecto cuando es puramente mecánica: reintentar con espera creciente es un bucle dentro de la closure, sin pasar por el reducer en cada intento.
return .run { send in
for intento in 0..<3 {
do {
await send(.respuesta(.success(try await cliente.cargarArticulos())))
return
} catch is FalloDeCarga {
try await clock.sleep(for: .seconds(pow(2.0, Double(intento))))
}
}
await send(.respuesta(.failure(.servidor(codigo: 503))))
}
Que el reloj sea una dependencia inyectada es lo que impide que este efecto vuelva tu suite de tests lenta: con un reloj de prueba, los siete segundos de espera se saltan avanzando el tiempo a mano, y el test sigue comprobando exactamente la misma lógica de reintento.
La costumbre de tratar los errores como excepciones enseña una asimetría que no resiste el análisis: el camino feliz se modela con cuidado, con tipos precisos y nombres del negocio, y el camino infeliz se despacha con un catch genérico y un mensaje. Pero el usuario no vive en el camino feliz; vive en un mundo donde el metro no tiene cobertura, la sesión caduca y el servidor se cae los martes. TCA fuerza a corregir esa asimetría por una vía indirecta y muy eficaz: como lo único que puede volver de un efecto son acciones, y las acciones son casos de un enum que tú escribes, el fallo no tiene otro camino que convertirse en una palabra de tu vocabulario. Y en el momento en que le pones nombre, ocurren tres cosas a la vez. La primera es que el compilador se vuelve tu aliado, porque un switch exhaustivo sobre el desenlace te obliga a decidir qué hace la aplicación cuando el mundo dice que no, en vez de dejar esa decisión implícita en un if olvidado. La segunda es que el fallo se vuelve testeable exactamente igual que el éxito: no hay que provocar una caída de red, basta con que la dependencia sustituida devuelva .failure(.sinConexion) y afirmar el estado resultante, de modo que los caminos infelices dejan de ser territorio inexplorado y pasan a tener la misma cobertura que el feliz. La tercera, y la más importante a largo plazo, es que la política de errores deja de estar dispersa por closures y vistas y se concentra en el reducer, donde puede leerse entera y discutirse con quien decide el producto. Que la cancelación esté deliberadamente excluida de todo esto lo confirma: la arquitectura distingue entre lo que salió mal y lo que simplemente dejó de importar, porque solo lo primero es información. Nombrar el fallo no es defensivo; es admitir que la mitad de tu aplicación ocurre cuando algo no sale.
- Añade un manejador
catch:a un efecto tuyo y comprueba, cancelándolo, que no se dispara. Explica por qué ese comportamiento es el correcto. - Quita el manejador, provoca un fallo y observa el aviso en tiempo de ejecución. Razona qué vería el usuario en producción sin ese aviso.
- Sustituye dos casos de acción separados —uno de éxito y otro de fallo— por un solo caso con
Result, y comprueba cuánta lógica común deja de estar duplicada. - Define un enum de fallo del dominio con cuatro casos y tradúcelo en la frontera de la dependencia, no en la vista.
- Escribe la política de cada caso en el reducer: reintento, navegación, registro o mensaje. Después añade un quinto caso y comprueba dónde te rompe la compilación.