wandres.dev
ERRORES · sin excepciones comprobadas

Modelar el fallo como valor: sealed, Either y comparación

Cuando los fallos de una operación se pueden enumerar, el sitio correcto para declararlos es el tipo de retorno. La lección construye la jerarquía sellada de error, separa el eje del éxito del eje del fallo, presenta el contenedor de dos ramas de la tradición funcional junto con el problema de encadenamiento que Kotlin no resuelve por sí solo, y compara los tres enfoques disponibles señalando el coste real de cada uno en lugar de vender el ganador.

⏱ 20 min

La afirmación de que conviene modelar los fallos como valores se repite tanto que ha perdido casi todo su contenido, y quien la aplica sin entenderla suele acabar con una jerarquía de treinta casos de error que nadie distingue y con capas enteras dedicadas a traducir un tipo de error en otro. La afirmación correcta es más estrecha y más útil: cuando quien escribe una función ya conoce y puede enumerar las formas en que termina mal, esa información existe en el momento de escribir la firma y tirarla es una pérdida gratuita, porque el sistema de tipos de Kotlin puede transportarla intacta hasta el invocador y obligarle a mirarla. Lo que esta lección se propone es mostrar cómo se construye esa jerarquía sin que degenere, presentar honestamente el precio que se paga en encadenamiento y en propagación entre capas, y poner las tres opciones disponibles una al lado de otra sin declarar un ganador universal, porque no lo hay.

🎯 Al terminar esta lección sabrás
  • Diseñar una jerarquía sellada que represente el desenlace de una operación con exhaustividad comprobada en el punto de consumo.
  • Separar el eje del éxito del eje del fallo para evitar que la jerarquía de errores se duplique en cada operación.
  • Explicar qué aporta el contenedor genérico de dos ramas frente a un tipo sellado propio y qué problema de encadenamiento arrastra.
  • Comparar excepciones, contenedor genérico y tipo del dominio en exhaustividad, coste, composición y adecuación a la frontera.

El tipo suma como desenlace de la operación

La construcción básica es directa: una interfaz sellada cuyos casos son los desenlaces posibles, consumida en un when que el compilador exige exhaustivo. La ganancia inmediata es que añadir un caso nuevo rompe la compilación en todos los sitios que lo consumen, que es exactamente la comprobación que las excepciones comprobadas prometían y no lograban dar de forma composicional.

sealed interface Retirada {
    data class Ok(val saldoRestante: Dinero) : Retirada
    data class SaldoInsuficiente(val disponible: Dinero) : Retirada
    data object CuentaBloqueada : Retirada
    data class LimiteDiarioSuperado(val limite: Dinero) : Retirada
}

fun retirar(cuenta: Id, importe: Dinero): Retirada = // ...

when (val r = retirar(cuenta, importe)) {
    is Retirada.Ok -> mostrarSaldo(r.saldoRestante)
    is Retirada.SaldoInsuficiente -> ofrecerRecarga(r.disponible)
    Retirada.CuentaBloqueada -> abrirSoporte()
    is Retirada.LimiteDiarioSuperado -> explicarLimite(r.limite)
}

Toda la garantía de ese fragmento descansa en un detalle fácil de destruir: no hay rama comodín. En el momento en que alguien añade una rama final que agrupa el resto de casos, el compilador deja de tener nada que exigir y la jerarquía sellada pasa a costar lo mismo que antes sin dar nada a cambio. La rama comodín es aceptable cuando el consumidor pertenece a otro módulo y quiere sobrevivir a la aparición de casos nuevos; dentro del propio módulo es casi siempre una renuncia disfrazada de prudencia.

Ese diseño tiene un defecto que aparece a la tercera operación: si cada una declara su propio tipo con su caso de éxito dentro, el caso de éxito y los de fallo quedan mezclados en la misma jerarquía y no se pueden reutilizar. La corrección consiste en separar los dos ejes, dejando el éxito como parámetro genérico y sellando únicamente el error.

sealed interface Salida<out T, out E> {
    data class Exito<T>(val valor: T) : Salida<T, Nothing>
    data class Fallo<E>(val error: E) : Salida<Nothing, E>
}

sealed interface ErrorRetirada {
    data class SaldoInsuficiente(val disponible: Dinero) : ErrorRetirada
    data object CuentaBloqueada : ErrorRetirada
}
💡
Por qué aparece Nothing en las dos declaraciones

Al marcar ambos parámetros como covariantes, cada rama puede fijar el parámetro que no usa en el tipo que no tiene valores. Como ese tipo es subtipo de todo, un éxito encaja donde se espere cualquier tipo de error y un fallo encaja donde se espere cualquier tipo de éxito, sin conversiones ni parámetros ficticios. Es el mismo mecanismo que permite que la lista vacía sirva para cualquier lista.

El contenedor de dos ramas y su letra pequeña

Ese tipo genérico de dos parámetros tiene nombre y tradición: es el contenedor conocido como Either, con una rama izquierda para el fallo y una derecha para el éxito. En Kotlin no está en la biblioteca estándar y llega mediante una biblioteca externa, típicamente Arrow, que además aporta lo que hace que el patrón sea utilizable: las operaciones de encadenamiento.

La diferencia con un tipo sellado propio es menor de lo que parece y consiste sobre todo en herramientas. El tipo del dominio se lee mejor porque sus casos tienen nombres del negocio; el contenedor genérico se compone mejor porque trae un catálogo de operaciones ya escrito y compartido por todo el ecosistema. Ambos son el mismo tipo suma por debajo.

flowchart TD
A[La operacion puede terminar mal] --> B{Puedo enumerar los fallos ahora}
B -- No --> C[Excepcion documentada y traducida en la frontera]
B -- Si --> D{Al invocador le sirve distinguirlos}
D -- No, solo si hubo fallo --> E[Tipo nulable]
D -- Si --> F{Necesito encadenar muchas operaciones}
F -- No --> G[Tipo sellado propio del dominio]
F -- Si --> H[Contenedor de dos ramas con encadenamiento]

Aquí está el precio real y el motivo por el que muchos equipos abandonan el enfoque a mitad de camino. Kotlin no tiene notación para secuenciar operaciones que devuelven un tipo envuelto, de modo que encadenar cinco pasos donde cualquiera puede fallar produce o bien una escalera de anidamiento, o bien una cadena de transformaciones aplanadas que se lee peor que el código original. La respuesta de Arrow es un ámbito con capacidad de interrupción que devuelve la apariencia secuencial, y es una buena respuesta, pero es una dependencia y una forma de escribir que el equipo entero debe conocer.

// Sin ayudas: el anidamiento crece con cada paso
val salida = validar(entrada).flatMap { v ->
    autorizar(v).flatMap { a ->
        ejecutar(a)
    }
}

// Con un ambito de interrupcion: vuelve a leerse como una secuencia
fun procesar(entrada: Entrada): Either<ErrorProceso, Recibo> = either {
    val v = validar(entrada).bind()
    val a = autorizar(v).bind()
    ejecutar(a).bind()
}

El segundo precio es la propagación entre capas. Si el repositorio devuelve su error, el servicio el suyo y el controlador el suyo, hacen falta funciones de traducción en cada salto, y ese trabajo es real. La mitigación habitual es tener pocos tipos de error, definidos por capa y no por operación, y aceptar que la capa más externa colapse todo en una representación de transporte.

Los tres enfoques, comparados sin favoritismo

🧨

Excepciones

Coste sintáctico nulo en el camino feliz y propagación automática a través de cualquier número de capas. A cambio, cero exhaustividad, invisibilidad en la firma, coste de construir la traza y ninguna ayuda en la revisión.

📦

Contenedor genérico de la biblioteca

Convierte el fallo en valor sin escribir tipos nuevos y encaja bien en fronteras donde el error no se puede enumerar. A cambio, el error queda reducido al supertipo de lo lanzable y no hay exhaustividad posible.

🧩

Tipo sellado del dominio

Exhaustividad comprobada, nombres del negocio en el tipo y refactorizaciones seguras. A cambio, tipos que escribir, traducción entre capas y encadenamiento verboso sin biblioteca de apoyo.

La comparación se ve mejor sobre la misma operación escrita tres veces, porque revela que lo que cambia no es la implementación sino la cantidad de información que la firma entrega al invocador antes siquiera de ejecutarse.

fun cobrar(t: Tarjeta): Recibo                          // puede lanzar, la firma calla
fun cobrar(t: Tarjeta): Result<Recibo>                  // puede fallar, no dice de que
fun cobrar(t: Tarjeta): Salida<Recibo, ErrorCobro>      // dice exactamente de que

Hay un cuarto candidato que conviene no olvidar porque suele ser la respuesta correcta y casi nunca se considera: el tipo nulable. Cuando la operación solo puede fallar de una manera y quien la llama no necesita saber más que si hubo valor o no, devolver un tipo nulable da exhaustividad, coste nulo y cero infraestructura. Escribir una jerarquía sellada de un solo caso de error es un gasto sin contrapartida.

Tampoco conviene olvidar que los tres enfoques conviven sin contradicción en un mismo sistema, y que un proyecto sano usa los tres a la vez en sitios distintos. Las averías de infraestructura viajan como excepciones hasta el borde del proceso; la frontera con bibliotecas ajenas usa el contenedor genérico durante unas pocas líneas; y las reglas del negocio, que son las que el compilador puede ayudar a no olvidar, viajan como tipos del dominio. Presentar la elección como una guerra entre bandos es el error de partida.

// Los tres, cada uno en su sitio, dentro de la misma funcion
fun procesarPago(p: Peticion): Salida<Recibo, ErrorPago> {
    val tarjeta = validar(p) ?: return Salida.Fallo(ErrorPago.TarjetaInvalida)
    return runCatching { pasarela.cobrar(tarjeta) }        // frontera ajena
        .fold(
            onSuccess = { Salida.Exito(it) },
            onFailure = { Salida.Fallo(ErrorPago.desde(it)) },   // traduccion
        )
}

El criterio de elección que resiste la práctica combina dos preguntas. La primera es si los fallos son enumerables en el momento de escribir la firma; si no lo son, ninguna cantidad de tipos lo arreglará y el canal de excepciones con traducción en la frontera es lo honesto. La segunda es si el invocador inmediato tiene algo distinto que hacer según el caso; si no lo tiene, distinguirlos es ceremonia. Solo cuando ambas respuestas son afirmativas el tipo del dominio paga su precio.

Un tipo de error es una promesa de compatibilidad, y por eso su verdadera unidad de diseño no es la operación sino la capa

Vale la pena examinar por qué las jerarquías de error degeneran con tanta regularidad, porque el fallo no está en la técnica sino en la unidad a la que se aplica. Cuando alguien declara un tipo sellado de errores para cada función, está tomando una decisión que se parece mucho más a publicar una interfaz que a escribir una firma: cada caso enumerado es una promesa a todos los consumidores presentes y futuros, y la exhaustividad que hace valiosa la construcción es exactamente lo que convierte cualquier caso nuevo en un cambio incompatible que rompe la compilación de quien la usa. Esa propiedad es magnífica dentro de un módulo, donde romper la compilación de tres sitios es justo el aviso que se quería, y es un problema serio a través de una frontera de biblioteca, donde el mismo aviso llega en forma de código ajeno que deja de compilar al actualizar una versión menor. De ahí se sigue casi todo lo demás. Se sigue que la granularidad correcta no es una operación sino un dominio o una capa, porque un tipo de error compartido por doce funciones relacionadas cambia mucho menos a menudo que doce tipos independientes. Se sigue que los errores deben llevar la información que el invocador necesita para decidir y no la que el implementador tenía a mano, porque cada campo expuesto es otra promesa. Se sigue que en la frontera más externa el colapso hacia una representación amplia no es una derrota sino la decisión correcta, ya que nadie al otro lado va a hacer un tratamiento distinto por caso. Y se sigue, sobre todo, que la pregunta de fondo nunca fue excepciones frente a valores, sino qué parte del comportamiento de fallo quiero congelar como contrato verificado y qué parte quiero conservar libre para poder cambiarla mañana sin romper a nadie.

⚔️ Construye y mide el coste
  1. Toma una función tuya que lance dos excepciones distintas y conviértela en una jerarquía sellada. Cuenta las líneas añadidas y decide si el cambio compensó.
  2. Refactoriza una jerarquía que mezcle el éxito con los fallos hacia la forma de dos parámetros con el tipo sin valores en cada rama.
  3. Encadena tres operaciones falibles sin ayudas y luego con un ámbito de interrupción. Compara la legibilidad y la cantidad de anidamiento.
  4. Elige un tipo de error que cruce dos capas y escribe la función de traducción. Argumenta qué información conviene perder en el salto.
  5. Busca una jerarquía tuya con un solo caso de fallo y valora sustituirla por un tipo nulable. Defiende la decisión con el criterio de las dos preguntas.