Errores y resultados: modelar el fallo como un valor
Una excepción que atraviesa capas es una salida oculta que ninguna firma declara y que ningún compilador obliga a manejar, y por eso el fallo llega a la interfaz como una sorpresa en lugar de como una posibilidad prevista. Esta lección construye la alternativa: un tipo Result propio y sellado, un vocabulario cerrado de fallos escrito en lenguaje de dominio, las operaciones que permiten encadenar sin desenvolver, el punto exacto donde el resultado muere y se convierte en estado, y las razones concretas por las que el Result de la biblioteca estándar no basta para este trabajo.
Hay una asimetría curiosa en cómo tratamos el éxito y el fracaso. El valor correcto se declara con cuidado: aparece en la firma, tiene un tipo preciso, y el compilador impide usarlo mal. El fallo, en cambio, viaja por un canal que ninguna firma menciona, que ninguna anotación obliga a declarar y que ningún compilador exige atender. El resultado de esa asimetría es previsible y se repite en todos los proyectos: el camino feliz está bien tipado y bien probado, y el camino de error es una colección de capturas escritas a posteriori, en sitios elegidos por casualidad, después de que un fallo llegase a producción. Modelar el fallo como un valor corrige justamente eso. No añade seguridad por elegancia funcional ni por gusto por los tipos: la añade porque convierte una posibilidad invisible en un dato que aparece en la firma, que hay que abrir para usar el resultado y que el compilador recuerda por ti cada vez que alguien añade una manera nueva de fracasar.
- Explicar por qué una excepción que cruza capas es una salida oculta y qué garantías destruye.
- Definir un tipo
Resultpropio y un vocabulario sellado de fallos en lenguaje de dominio. - Encadenar operaciones sobre resultados sin desenvolverlos en cada paso.
- Situar con precisión el punto donde el resultado muere y se convierte en estado de la pantalla.
La excepción es una salida oculta
Una función que declara devolver Producto y puede lanzar una excepción está mintiendo por omisión: su verdadero conjunto de resultados incluye caminos que la firma no menciona. En Kotlin no existen excepciones comprobadas, de modo que nada obliga a documentarlas ni a manejarlas, y el conocimiento de qué puede fallar se transmite por tradición oral o leyendo la implementación, que es exactamente lo que una interfaz debía evitar.
El problema se agrava porque el mecanismo funciona bien en el sitio para el que fue diseñado y mal en todos los demás. Una excepción es un instrumento excelente para abortar una operación cuando ocurre algo que el programa no contemplaba, y su capacidad de saltar sin escalas hasta un manejador lejano es exactamente lo que hace falta en ese caso. Es un instrumento pésimo, en cambio, para comunicar una condición perfectamente prevista, como que no hay conexión, porque salta también las capas que sí sabían qué hacer con ella y aterriza donde ya no queda contexto para decidir.
Conviene además no confundir esta discusión con una preferencia estilística entre paradigmas. El punto no es que las excepciones sean impuras, sino que su ausencia en la firma impide que el compilador colabore, y sin esa colaboración el manejo de errores queda a merced de la memoria de quien escribe. Todo lo demás se sigue de ahí.
De esa invisibilidad se siguen tres daños concretos. El primero es que el manejo acaba lejos del lugar donde había contexto para decidir: una captura amplia en el ViewModel recibe el fallo de la red y el de la base de datos con el mismo aspecto, cuando cada uno merecía una respuesta distinta. El segundo es que la captura amplia atrapa también lo que no debía: errores de programación que deberían tumbar la operación y hacerse visibles quedan enterrados en un mensaje genérico, y la aplicación sigue funcionando en un estado que nadie previó. El tercero, específico de las corrutinas, es que una captura de Exception sin cuidado atrapa la cancelación y rompe la concurrencia estructurada.
Modelar el fallo como valor no significa abolir las excepciones. Significa repartir el trabajo según su naturaleza. Los fallos esperados —sin conexión, sesión caducada, recurso ausente, validación rechazada— son parte del comportamiento normal del sistema y deben ser valores. Los fallos inesperados —un índice fuera de rango, una conversión imposible, un estado que la lógica declaraba imposible— son errores de programación, no del entorno, y deben seguir siendo excepciones que interrumpan y lleguen al informe de fallos. Convertir un error de programación en un Result es tan dañino como convertir una condición de red en una excepción: el primero se oculta y el segundo se ignora.
Un tipo propio y un vocabulario cerrado
La biblioteca estándar de Kotlin ofrece un Result, y conviene entender por qué no es el adecuado aquí. Su lado de error es Throwable, es decir, un tipo abierto que no se puede recorrer de forma exhaustiva; nada impide que dentro llegue cualquier cosa, y por tanto la pantalla vuelve a tener que inspeccionar tipos de infraestructura para decidir qué mostrar. Un tipo propio con dos parámetros resuelve las dos carencias a la vez.
sealed interface Resultado<out T, out E> {
data class Exito<out T>(val valor: T) : Resultado<T, Nothing>
data class Fallo<out E>(val error: E) : Resultado<Nothing, E>
}
sealed interface FalloDeDatos {
data object SinConexion : FalloDeDatos
data object SesionCaducada : FalloDeDatos
data object NoEncontrado : FalloDeDatos
data class Servidor(val codigo: Int) : FalloDeDatos
data class Validacion(val campo: String, val motivo: String) : FalloDeDatos
}
Hay una segunda carencia del tipo estándar, menos citada y más molesta en la práctica: al estar implementado como clase envolvente en línea, sufre restricciones históricas al aparecer como tipo de retorno y obliga a construcciones incómodas en funciones suspendidas. Un tipo sellado propio no tiene ninguna de esas limitaciones, se recorre con when exhaustivo como cualquier otra jerarquía y encaja de forma natural en las herramientas del lenguaje que ya se usan para el estado y para los efectos.
La varianza de esos parámetros no es un adorno académico: Nothing como subtipo de todo permite que un Exito encaje en cualquier tipo de resultado sin necesidad de anotar el lado del error, lo que hace que el uso cotidiano resulte tan ligero como el de la excepción sin perder la información de tipos.
Una advertencia sobre el alcance del vocabulario evita el error más común al adoptarlo. La tentación es declarar una jerarquía única de errores para toda la aplicación, y esa jerarquía crece hasta que ninguna operación puede fallar de la mayoría de las formas que enumera, con lo que cada when acaba lleno de ramas imposibles que alguien debe rellenar con algo. Es preferible un vocabulario por área, ajustado a lo que esa área puede fracasar de verdad, y una traducción explícita cuando un fallo cruza de un área a otra. Un when de cuatro ramas donde las cuatro son posibles vale más que uno de veinte donde dieciséis son ruido.
El vocabulario de fallos merece el mismo cuidado que el de efectos de un nivel anterior, y por el mismo motivo. Está escrito en lenguaje de negocio y no de protocolo: no dice HttpError401 sino SesionCaducada, porque quien consume no debe saber que existe HTTP. Es cerrado, de modo que un when sobre él es exhaustivo y añadir un fallo nuevo rompe la compilación en cada lugar que debía atenderlo. Y transporta consigo lo necesario para reaccionar: Validacion lleva el campo y el motivo, porque una pantalla que solo recibe una etiqueta genérica no puede señalar el campo culpable.
flowchart LR N[Fuente remota lanza excepcion] --> A[Aduana del repositorio] L[Fuente local lanza excepcion] --> A A -->|traduce| R[Resultado con fallo de dominio] R --> VM[ViewModel decide] VM --> S[Estado con mensaje y accion] style A fill:#f9e2af,color:#11111b style S fill:#a6e3a1,color:#11111b
Encadenar sin desenvolver
Un tipo de resultado sin operaciones de composición es peor que las excepciones, porque obliga a comprobar en cada paso y llena el código de escaleras de condicionales. Las tres operaciones que hacen falta son pocas y bien conocidas.
map
Transforma el valor de éxito y deja el fallo intacto. Sirve para convertir tipos sin escribir ninguna comprobación.
flatMap
Encadena otra operación que también puede fallar. El primer fallo detiene la cadena y se propaga sin ejecutar el resto.
fold
Colapsa ambos lados en un único valor. Es la operación terminal, la que usa quien ya no puede seguir aplazando la decisión.
mapError
Traduce el fallo de un vocabulario a otro. Es la herramienta de la aduana entre capas con vocabularios distintos.
suspend fun cargarResumen(id: PedidoId): Resultado<Resumen, FalloDeDatos> =
pedidos.detalle(id)
.flatMap { pedido -> usuarios.perfil(pedido.clienteId) }
.map { perfil -> Resumen(perfil.nombre, perfil.direccion) }
Vale la pena delimitar dónde no debe usarse el tipo, porque el exceso lo desacredita más deprisa que cualquier crítica. Una función pura que no puede fallar no necesita envolverse; un parse cuya única forma de fracaso es un valor mal formado se expresa mejor devolviendo un valor opcional; y una precondición que solo se incumple por un error de programación merece una excepción, no una rama que alguien tendrá que manejar en cinco pantallas. El tipo de resultado es para las fallas del entorno, aquellas que ocurrirán aunque el código sea correcto, y reservarlo a ese papel es lo que mantiene informativa su presencia en una firma.
Esa cadena tiene la misma legibilidad que la versión con excepciones y una diferencia decisiva: cualquiera que lea la firma sabe que la operación puede fallar y con qué vocabulario, sin abrir la implementación. La propagación en cortocircuito, que en la versión con excepciones ocurría por un mecanismo del lenguaje invisible en el código, aquí es una propiedad explícita de flatMap que se puede probar con un test.
La conversión entre ambos mundos debe ocurrir en un único punto por capa. En el repositorio, una función de ayuda envuelve la llamada peligrosa, captura los tipos concretos que sabe traducir, relanza la cancelación y devuelve un resultado. A partir de ahí, ninguna capa superior vuelve a escribir un bloque de captura. Si aparecen capturas dispersas en el ViewModel, es señal de que la aduana tiene un agujero, y el remedio es cerrarlo abajo en lugar de parchearlo arriba.
Dónde muere el resultado
Un resultado no debe viajar indefinidamente: tiene un final natural, y situarlo bien es lo que evita que el tipo se convierta en ceremonia. Ese final está en el punto donde alguien tiene autoridad para decidir qué se le cuenta al usuario, es decir, en el ViewModel, justo antes de producir el estado.
fun refrescar() = intent {
reduce { state.copy(sincronizando = true) }
val resultado = repo.refrescar(state.categoria)
reduce {
when (resultado) {
is Resultado.Exito -> state.copy(sincronizando = false, aviso = null)
is Resultado.Fallo -> state.copy(sincronizando = false, aviso = resultado.error.aAviso())
}
}
}
Situar ese final demasiado arriba o demasiado abajo produce los dos abusos habituales. Demasiado abajo significa que el repositorio decide por su cuenta tragarse un fallo y devolver una lista vacía, con lo que la pantalla no puede distinguir entre no haber datos y no haber podido pedirlos, que son situaciones opuestas para el usuario. Demasiado arriba significa que el resultado llega hasta la función de composición y la interfaz acaba con ramas de manejo de errores repartidas por el árbol de vistas, justo donde peor se prueban. El punto correcto es el único lugar que conoce a la vez el fallo y el contexto de la pantalla.
El when exhaustivo sobre el fallo es donde se paga el trabajo de haber cerrado el vocabulario. Cada rama decide algo distinto y decide aquí, con toda la información disponible: si hay contenido en pantalla, un fallo de conexión es un aviso discreto; si no lo hay, es un estado vacío con acción de reintento; una sesión caducada no es un mensaje sino una navegación. Ninguna de esas decisiones podía tomarse abajo, donde no se sabe qué hay en pantalla, ni tomarse mediante una excepción, que no distingue casos sin inspeccionar tipos.
Al haber convertido el fallo en dato, la prueba del camino de error deja de necesitar andamiaje. Un repositorio falso que devuelva un fallo concreto no requiere simular una desconexión ni provocar una excepción en el momento justo: devuelve un valor, y la afirmación compara estados. Esa facilidad tiene un efecto medible sobre la cobertura real, porque el motivo por el que los caminos de error casi nunca se prueban no es que no importen, sino que reproducirlos costaba demasiado.
No todos los fallos terminan en el estado, y distinguirlo bien es parte del mismo trabajo. Una sesión caducada no describe una situación que la pantalla deba mostrar de forma persistente: describe una orden de salir de ahí, y por tanto su destino natural es un efecto de una sola vez, emitido con postSideEffect, y no un campo del estado que quedaría fosilizado tras la navegación. La regla que ordena la decisión es la misma que rige para cualquier otro dato: si describe cómo debe verse la pantalla ahora, es estado; si describe algo que debe ocurrir una vez, es efecto. Un fallo puede caer en cualquiera de los dos lados según lo que se decida hacer con él.
Conviene además que el estado guarde el fallo como valor de dominio y no como texto ya traducido. Un estado que contiene la cadena literal del mensaje ata la lógica al idioma y a la biblioteca de recursos, complica los tests y estropea el cambio de configuración. Guardar el caso sellado y resolver el texto en la capa de presentación conserva la pureza del estado y permite comprobar en un test que el fallo fue una sesión caducada sin comparar frases.
El argumento decisivo a favor de modelar el fallo como valor no es la seguridad, aunque la haya, ni la elegancia, que es discutible: es lo que ocurre con el momento en que se piensa en el error. Con excepciones, los fallos se piensan tarde, casi siempre después de verlos, porque nada durante la escritura del camino feliz obliga a considerarlos; la firma no los menciona, el compilador no los reclama y la revisión de código no los echa de menos. El resultado es que el manejo de errores de la mayoría de las aplicaciones es un sedimento de reacciones a incidentes, no un diseño, y se nota en que dos pantallas de la misma aplicación responden de forma distinta a la misma condición de red. Cuando el fallo está en el tipo de retorno, ese momento se adelanta: aparece mientras se escribe la firma, antes de la primera línea de implementación, y obliga a responder entonces a la única pregunta que importa, cuáles son las maneras legítimas de que esta operación no funcione. Esa pregunta, hecha a tiempo, es de diseño y no de reparación, y sus respuestas se convierten en un vocabulario compartido que documenta el sistema mejor que cualquier comentario. Hay un segundo efecto que solo se aprecia con el tiempo. Un vocabulario cerrado de fallos hace que añadir una forma nueva de fracasar sea un acto visible: rompe la compilación en cada lugar que debía atenderla y obliga a alguien a decidir qué se muestra en cada pantalla. Con excepciones, esa misma incorporación es silenciosa, compila perfectamente y solo se manifiesta cuando un usuario encuentra un mensaje genérico que no explica nada. La diferencia entre ambos mundos no está en la cantidad de errores que ocurren, que es la misma, sino en quién los descubre y cuándo: el compilador durante la revisión, o el usuario en producción. Elegir el tipo es elegir al primero.
- Recorre un repositorio tuyo y enumera todas las formas reales en que sus operaciones pueden fracasar. Escríbelas como jerarquía sellada usando lenguaje de negocio.
- Sustituye las excepciones que cruzan capas por un resultado propio y comprueba que las firmas ahora declaran el fallo.
- Localiza toda captura amplia de excepciones fuera del módulo de datos y elimínala, moviendo la traducción a la aduana del repositorio.
- Reescribe una secuencia de tres operaciones dependientes con
flatMapy verifica con un test que un fallo intermedio corta la cadena. - Sustituye en tu estado los mensajes de texto por casos sellados y comprueba que los tests de presentación ya no comparan cadenas de idioma.