wandres.dev
SEALED Y EXHAUSTIVIDAD · modelar el dominio cerrado

Exhaustividad: cuando el compilador te obliga

Por qué un when sobre un tipo cerrado debe cubrir todos los casos, qué cambió al volverse obligatoria también en sentencias, cómo la resolución sensible al contexto de Kotlin 2.3 limpia las ramas, y qué ocurre exactamente el día que añades un caso nuevo.

⏱ 14 min

La exhaustividad es la contrapartida del cierre: si el compilador sabe que los casos son exactamente esos, puede exigirte que los trates todos. Ese es el momento en que sellar deja de ser una decoración del modelo y se convierte en una herramienta de refactorización, porque cada caso nuevo se transforma en una lista de errores de compilación que apunta con el dedo a cada sitio donde falta una decisión. Sin exhaustividad, sealed sería poco más que documentación; con ella, es una red que sostiene el dominio mientras cambia.

🎯 Al terminar esta lección sabrás
  • Saber cuándo when exige cobertura completa y cuándo se conforma con lo que le des.
  • Distinguir when como expresión de when como sentencia, y qué cambió al endurecerse la regla.
  • Escribir ramas sin repetir el nombre del tipo gracias a la resolución sensible al contexto.
  • Anticipar el efecto de añadir un caso nuevo y por qué el else lo desactiva.

Cuándo when obliga a cubrirlo todo

Un when usado como expresión, es decir, cuyo valor se asigna o se devuelve, siempre tuvo que ser exhaustivo: el compilador necesita garantizar que la expresión produce un valor en todos los caminos. Con un sujeto de tipo cerrado, la comprobación se hace caso por caso.

sealed interface Pago {
    data class Tarjeta(val ultimos4: String) : Pago
    data class Transferencia(val iban: String) : Pago
    data object Efectivo : Pago
}

fun descripcion(p: Pago): String = when (p) {
    is Pago.Tarjeta -> "tarjeta acabada en " + p.ultimos4
    is Pago.Transferencia -> "transferencia a " + p.iban
    Pago.Efectivo -> "efectivo"
}

Fíjate en dos cosas. No hay else y aun así compila, porque los tres casos agotan la jerarquía. Y dentro de cada rama p ya es del subtipo concreto sin ningún casteo: el smart cast funciona porque el compilador conoce el tipo exacto tras la comprobación. Ese acceso directo a ultimos4 sin nulos ni conversiones es la mitad práctica de la exhaustividad.

Los sujetos que admiten esta comprobación son los tipos cerrados: jerarquías selladas, enums y Boolean, además de sus variantes anulables, donde null cuenta como un caso más que hay que cubrir.

Sentencia frente a expresión

Durante mucho tiempo hubo un agujero incómodo: un when que no devolvía valor, usado como pura sentencia, podía olvidarse casos sin que el compilador dijera nada. Era exactamente el sitio donde más daño hacía, porque los when que ejecutan efectos (navegar, emitir, escribir) suelen ser los que más ramas acumulan.

Kotlin cerró ese agujero por fases: primero como aviso y, con el lenguaje 2.0 y el frontend K2, como error para sujetos de tipo cerrado. Hoy esto no compila si falta un caso.

// sentencia: no produce valor, pero igualmente debe cubrir la jerarquia
fun registrar(p: Pago) {
    when (p) {
        is Pago.Tarjeta -> log.info("tarjeta")
        is Pago.Transferencia -> log.info("transferencia")
        Pago.Efectivo -> log.info("efectivo")
    }
}

Merece la pena entender por qué se tardó tanto: hacerlo obligatorio rompía código existente, y Kotlin trata la compatibilidad como un compromiso serio. La lección de diseño es que la exhaustividad no es gratis en un lenguaje vivo, y por eso conviene aprovecharla desde el primer día en el código nuevo en vez de arrastrar else heredados.

ℹ️
Kotlin 2.3: ramas sin repetir el nombre del tipo

La resolución sensible al contexto, mejorada en Kotlin 2.3, permite omitir el nombre del tipo al referirte a entradas de enum o a subclases selladas dentro de un when cuyo tipo esperado ya se conoce. En el ejemplo anterior bastaría escribir is Tarjeta, is Transferencia y Efectivo, porque el sujeto es un Pago y el compilador sabe dónde buscar esos nombres. El efecto no es solo cosmético: al desaparecer el prefijo repetido, anidar jerarquías selladas deja de castigar la legibilidad y las tablas de decisión largas se leen como lo que son, una lista de casos.

Qué pasa al añadir un caso

Aquí está el verdadero retorno de la inversión. Añade un caso a la jerarquía y compila.

sealed interface Pago {
    data class Tarjeta(val ultimos4: String) : Pago
    data class Transferencia(val iban: String) : Pago
    data object Efectivo : Pago
    data class Cripto(val red: String, val direccion: String) : Pago  // nuevo
}

El compilador falla en todos los when exhaustivos del módulo, uno por uno, con el mensaje de que la rama Cripto no está cubierta. Eso no es una molestia: es un inventario automático y completo de todos los puntos del programa donde alguien tomaba una decisión basada en el conjunto de casos y ahora debe revisarla. Ninguna búsqueda de texto, ninguna prueba y ninguna revisión de código ofrece esa garantía.

flowchart TD
A[Anado un caso a la jerarquia sellada] --> B[Los when sin else fallan al compilar]
B --> C[Lista exacta de sitios que decidian por caso]
C --> D[Reviso cada uno y decido de verdad]
A --> E[Los when con else compilan igual]
E --> F[El caso nuevo cae en la rama por defecto]
F --> G[Fallo silencioso en produccion]
style D fill:#a6e3a1,color:#11111b
style G fill:#f38ba8,color:#11111b

Por eso el else sobre un tipo cerrado es casi siempre un error de diseño disfrazado de comodidad: apaga exactamente la señal que justificaba sellar. Si de verdad hay un grupo de casos que se tratan igual, nómbralos explícitamente separados por comas; el día que aparezca uno nuevo querrás que el compilador te pregunte a cuál de los dos grupos pertenece.

// mejor esto, que sigue siendo exhaustivo y sigue avisando
val requiereBanco = when (p) {
    is Pago.Transferencia -> true
    is Pago.Tarjeta, Pago.Efectivo, is Pago.Cripto -> false
}

Queda un matiz de honestidad: si consumes una jerarquía sellada publicada por otro módulo, el else deja de ser opcional y pasa a ser una defensa razonable, porque el autor puede añadir casos en una versión futura sin recompilarte. Esa asimetría entre quien es dueño de la jerarquía y quien la consume es el tema de la última lección.

Los bordes de la comprobación

La garantía es potente pero tiene fronteras precisas, y confundirlas produce la peor combinación posible: creerse protegido sin estarlo.

El when sin sujeto, esa cadena de condiciones booleanas independientes, nunca se comprueba por exhaustividad. El compilador no tiene forma de saber que tus condiciones agotan el espacio, así que como expresión exigirá siempre un else. Si escribes la lógica de casos así, has renunciado a la red aunque el tipo esté sellado.

// esto NO esta protegido: son condiciones sueltas, no casos de un tipo
val texto = when {
    p is Pago.Tarjeta -> "tarjeta"
    p is Pago.Efectivo -> "efectivo"
    else -> "otro"          // obligatorio, y silencia cualquier caso nuevo
}

El segundo borde es la nulabilidad. Si el sujeto es de tipo anulable, null es un caso más que hay que cubrir explícitamente; el compilador lo cuenta y lo exige.

fun etiqueta(p: Pago?): String = when (p) {
    null -> "sin pago"
    is Pago.Tarjeta -> "tarjeta"
    is Pago.Transferencia -> "transferencia"
    Pago.Efectivo -> "efectivo"
    is Pago.Cripto -> "cripto"
}

El tercero es el tipo del sujeto. Solo los tipos cerrados se comprueban: jerarquías selladas, enums y Boolean. Un when sobre Int o sobre String siempre necesitará else, por muchas ramas que escribas, porque el conjunto de valores no está cerrado en ninguna parte.

La exhaustividad convierte al compilador en la lista de tareas del refactor

Casi todo el mundo llega a sealed buscando expresividad, un modelo que se lea bien, y descubre tarde que el valor real está en otra parte: en lo que ocurre seis meses después, cuando el dominio cambia. Un sistema de tipos que solo describe es documentación; uno que además obliga es una herramienta de mantenimiento. Cuando añades un caso a una jerarquía cerrada y el compilador te devuelve dieciocho errores, esos dieciocho errores no son un obstáculo sino el mapa completo, y demostrablemente completo, de todos los lugares del programa que razonaban sobre el conjunto de casos. Compara eso con la alternativa habitual, la cadena de comprobaciones con un else final o el if sobre banderas: ahí el caso nuevo se cuela por el camino por defecto, el programa sigue compilando, las pruebas siguen en verde, y el error aparece semanas después en un log de producción como un comportamiento inexplicable que nadie relaciona con aquel cambio. La diferencia entre las dos situaciones no está en la inteligencia del equipo ni en la calidad de la revisión de código, sino en dónde se ha puesto el conocimiento: en la cabeza de quien escribió el código o en el sistema de tipos. Solo lo segundo sobrevive a la rotación de personas y al olvido. De ahí la disciplina que de verdad importa en este nivel y que cuesta interiorizar porque va contra la comodidad inmediata: al escribir un when sobre un tipo que tú controlas, nunca pongas else; acepta el ruido de enumerar todas las ramas hoy a cambio de que el compilador te avise mañana. Cada else que escribes sobre una jerarquía propia es una renuncia silenciosa a la única garantía que justificaba cerrarla.

⚔️ Provoca el fallo a propósito
  1. Escribe una jerarquía sellada con tres casos y dos funciones que hagan when exhaustivo sobre ella.
  2. Añade un cuarto caso y cuenta cuántos errores de compilación aparecen: esa es tu lista de revisión.
  3. Añade un else a una de las dos funciones, repite el experimento y observa el error que ya no aparece.
  4. Convierte un when con else de tu código real en uno exhaustivo y anota qué caso descubriste que faltaba.
  5. Prueba a omitir el nombre del tipo en las ramas y comprueba el comportamiento con la resolución sensible al contexto de tu versión.