sealed interface y sealed class: jerarquias cerradas y el when exhaustivo
Las jerarquias selladas son la forma que tiene Kotlin de expresar un tipo suma: un valor que es exactamente uno de una lista finita y conocida de casos. Esta leccion explica por que esa clausura en tiempo de compilacion es lo que permite que el when sea exhaustivo, y por que esa exhaustividad convierte al compilador en un revisor que nunca olvida una rama. Compara sealed interface con sealed class y con enum, aclara cuando conviene data object frente a data class dentro de la jerarquia, y muestra el uso canonico en MVI: modelar los intents como el vocabulario cerrado de lo que el usuario puede pedir y los side effects como el vocabulario cerrado de lo que ocurre una sola vez, dos jerarquias distintas con destinos distintos.
Si la data class responde a la pregunta “¿de qué esta hecho este valor?”, la jerarquia sellada responde a la contraria: “¿cual de estos valores es?”. Son las dos mitades de la manera algebraica de modelar datos —el producto y la suma— y una arquitectura de estado necesita ambas por igual. Lo que hace especial al modificador sealed no es que agrupe subtipos, cosa que ya hacia la herencia corriente, sino que los cierra: le promete al compilador que la lista de casos esta completa y que nadie podra añadir uno mas desde fuera. Esa promesa, aparentemente restrictiva, es lo que desbloquea la propiedad mas valiosa del capitulo: un when que el compilador verifica por ti, que se niega a compilar cuando olvidas una rama y que convierte cada caso nuevo del dominio en una lista automatica de todos los sitios que hay que actualizar.
- Comprender la jerarquia sellada como tipo suma y qué significa exactamente cerrar una jerarquia.
- Elegir con criterio entre
sealed interface,sealed classyenum classsegun el caso a modelar. - Explotar el
whenexhaustivo como expresion y entender por qué elelsedesactiva la garantia. - Modelar los intents y los side effects como dos jerarquias cerradas independientes.
Cerrar una jerarquia: qué le prometes al compilador
El nombre del modificador describe bien lo que hace, siempre que se entienda a qué se opone. No sella la clase contra la herencia —para eso ya existe la ausencia de open, que es el defecto en Kotlin—, sino contra la herencia desde fuera: los casos se pueden extender, pero solo dentro del recinto donde el compilador puede verlos todos a la vez.
Una clase abierta es una invitacion: cualquiera, en cualquier modulo, puede heredar de ella y crear un subtipo que tu codigo nunca vio. Es util para extensibilidad, y es catastrofico para el razonamiento exhaustivo, porque impide afirmar nada sobre el conjunto de casos posibles. sealed invierte el trato: los subtipos directos deben declararse en el mismo paquete y modulo de compilacion, de modo que el compilador conoce la lista entera y puede razonar sobre ella.
sealed interface Contenido {
data object Cargando : Contenido
data class Error(val mensaje: String) : Contenido
data class Lista(val tareas: List<Tarea>) : Contenido
}
La palabra tecnica para esto es tipo suma, y viene de la aritmetica de los tipos: la cardinalidad del conjunto resultante es la suma de las cardinalidades de sus casos, no el producto, como ocurre cuando se combinan campos independientes en una data class. La leccion siguiente convierte ese recuento en una herramienta de diseño; aqui basta con retener la intuicion de que sumar casos hace crecer el espacio de estados de forma lineal mientras que multiplicar campos lo hace explotar.
Lee el bloque anterior como una definicion matematica antes que como codigo: Contenido es la union disjunta de tres alternativas, y solo de esas tres. No existe un cuarto contenido posible porque el tipo no lo admite. Frente al enfoque de tres campos independientes —un booleano de carga, un texto de error opcional y una lista opcional—, que genera ocho combinaciones de las cuales cinco no significan nada, aqui hay exactamente tres estados y los tres son validos.
Conviene precisar hasta donde llega el cierre, porque la regla ha cambiado con las versiones del lenguaje y se cuenta mal a menudo. En Kotlin moderno los subtipos directos deben vivir en el mismo paquete y en el mismo modulo de compilacion, pero no estan obligados a estar anidados dentro de la declaracion ni en el mismo fichero. Anidarlos, como en el ejemplo, es una convencion que agrupa la definicion completa en un solo bloque legible; declararlos aparte es legitimo cuando cada caso arrastra mucho codigo. Lo que no puede ocurrir jamas es que un modulo ajeno añada un caso, y esa imposibilidad es justo lo que el compilador necesita para razonar.
Observa tambien la eleccion entre data object y data class dentro de la jerarquia. Un caso sin informacion asociada, como Cargando, no necesita instanciarse mil veces: es un singleton, y data object le da ademas un toString y un equals sensatos. Un caso que transporta datos, como Error o Lista, es una data class con los campos que ese caso —y solo ese caso— necesita. La informacion vive donde es pertinente en vez de vivir suelta en el estado por si acaso.
sealed interface, sealed class y enum: tres cierres distintos
Los tres construyen conjuntos finitos, pero no son intercambiables. Un enum cierra un conjunto de constantes: cada caso es un unico valor sin campos propios variables, lo cual lo hace ideal para vocabularios simples como un filtro o un orden. Una jerarquia sellada cierra un conjunto de tipos: cada caso puede llevar su propia forma, y por eso es la unica de las tres capaz de modelar alternativas con datos heterogeneos.
enum class Filtro { Todas, Pendientes, Hechas } // constantes, misma forma
sealed interface Resultado { // tipos, formas distintas
data class Exito(val datos: List<Tarea>) : Resultado
data class Fallo(val causa: Throwable, val reintentable: Boolean) : Resultado
}
El sintoma clasico de haber elegido mal es el enum que empieza a llevar campos opcionales. Alguien necesita adjuntar un mensaje solo al caso de error, añade una propiedad anulable a la constante y, a partir de ahi, todos los casos cargan un campo que solo uno usa, y el codigo se llena de comprobaciones para saber si ese mensaje esta o no esta. Ese es el momento exacto de convertir el enum en jerarquia sellada: la informacion baja al caso que la necesita y las combinaciones sin sentido desaparecen del universo de lo representable.
Entre sealed interface y sealed class la diferencia es de herencia. Una sealed class es una clase: puede tener constructor, estado propio y ofrecer implementaciones a sus hijos, pero cada subtipo solo puede heredar de una. Una sealed interface no tiene constructor ni estado, y a cambio un mismo tipo puede implementar varias, lo que permite construir clasificaciones cruzadas —un caso puede pertenecer a la vez a la jerarquia de errores y a una interfaz marcadora de reintentables—. La regla practica de la comunidad de Kotlin es empezar por sealed interface y bajar a sealed class solo cuando de verdad necesites compartir estado o implementacion entre los casos.
flowchart TB I[sealed interface Intent] --> A[data object CargarPantalla] I --> B[data class TextoCambiado con texto] I --> C[data class TareaMarcada con id] I --> D[data object Reintentar] W[when sobre Intent] -->|el compilador exige las cuatro ramas| OK[Reducer completo garantizado] style I fill:#cba6f7,color:#11111b style OK fill:#a6e3a1,color:#11111b
El when exhaustivo: el compilador como revisor que no olvida
Aqui esta el pago de haber cerrado la jerarquia. Cuando un when se usa como expresion —es decir, cuando su resultado se asigna o se devuelve— y el sujeto es de un tipo sellado, el compilador exige que todas las ramas esten cubiertas. Falta una y el codigo no compila.
fun render(contenido: Contenido): Pantalla = when (contenido) {
Contenido.Cargando -> Pantalla.Spinner
is Contenido.Error -> Pantalla.Aviso(contenido.mensaje)
is Contenido.Lista -> Pantalla.Filas(contenido.tareas)
}
Dos detalles tecnicos que conviene tener claros. El primero es el smart cast: dentro de la rama is Contenido.Error, el compilador sabe que el sujeto es de ese tipo y te deja acceder a mensaje sin conversion explicita. La informacion que solo pertenece a un caso queda accesible exactamente en el sitio donde tiene sentido. Una variante util del mismo mecanismo es capturar el sujeto en la propia expresion, escribiendo el when sobre una variable declarada en el sitio. Ahorra repetir el nombre en cada rama y, sobre todo, garantiza que el valor examinado es el mismo en todas ellas, cosa que deja de ser evidente cuando el sujeto es una propiedad de un objeto que otro hilo podria cambiar.
El segundo es que la exhaustividad es una propiedad de la expresion: usado como sentencia y descartando el resultado, un when incompleto compila sin protestar en codigo antiguo. Escribir siempre el when como expresion, aunque sea devolviendo Unit, es la manera de no perder la red de seguridad.
Existe ademas una variante del when que se usa poco y resuelve muy bien la logica de transicion: la forma sin sujeto, donde cada rama es una condicion booleana completa. Sirve cuando la decision no depende de un solo valor sino de una combinacion, y mantiene plano lo que de otro modo seria una escalera de condicionales anidados. El precio es que pierdes la exhaustividad, asi que su sitio esta en la logica auxiliar y nunca en el despacho principal sobre el tipo sellado.
val nivel = when {
intentos == 0 -> Aviso.Ninguno
intentos < 3 && sinConexion -> Aviso.Reintentando
else -> Aviso.Bloqueado
}
Y de ahi se sigue la regla que mas veces salva un refactor: nunca pongas else en un when sobre un tipo sellado. El else es una rama comodin que absorbe cualquier caso futuro, y con ello desactiva justamente lo que fuiste a buscar. Con else, añadir un caso nuevo a la jerarquia compila en silencio y falla en produccion. Sin else, añadirlo produce una lista de errores de compilacion que es, literalmente, el inventario de todos los lugares que debes actualizar. El compilador deja de ser un obstaculo y pasa a ser el unico revisor que jamas se distrae.
Intents y efectos: dos vocabularios cerrados, dos destinos
En MVI la jerarquia sellada aparece dos veces, y distinguir esas dos apariciones es la mitad del diseño. La primera es el Intent: el vocabulario completo de lo que el usuario puede pedirle a una pantalla. Escribirlo como jerarquia cerrada convierte una pregunta difusa —“¿qué se puede hacer aqui?”— en un fichero que se lee de un vistazo, y garantiza que el reducer trate todos los casos.
sealed interface TareasIntent {
data object Cargar : TareasIntent
data class TextoCambiado(val texto: String) : TareasIntent
data class TareaMarcada(val id: String) : TareasIntent
}
sealed interface TareasEfecto {
data class MostrarSnackbar(val mensaje: String) : TareasEfecto
data object NavegarAtras : TareasEfecto
}
Repara en que los casos sin datos y los casos con datos conviven sin friccion en la misma jerarquia: Cargar no necesita transportar nada y es un data object; TextoCambiado lleva su texto porque sin el la intencion es incompleta. Esa asimetria, imposible de expresar con un enum, es justo lo que permite que cada intencion cargue exactamente su carga util y ni un campo mas.
La segunda es el SideEffect: el vocabulario de lo que ocurre una sola vez y no pertenece al estado —una navegacion, un aviso efimero, una vibracion—. La separacion es deliberada y no es simetrica en su destino: los intents entran y se convierten en estado nuevo; los efectos salen y se consumen sin dejar rastro. Mezclarlos en una sola jerarquia parece economico y produce el bug clasico del mensaje que se vuelve a mostrar al girar la pantalla, porque un evento efimero se guardo en algo que se reproduce.
La prueba para saber en cual de las dos va un caso nuevo es preguntar qué deberia pasar si el usuario rota el dispositivo justo despues. Si la respuesta es que el resultado debe seguir visible, era estado y entro por la via del intent. Si la respuesta es que no debe repetirse, era un efecto y su sitio es el canal de una sola vez.
Una tercera aparicion, menos comentada pero igual de util, es la del propio contenido del estado, que ya viste en la leccion anterior. Merece la pena tener presente el mapa completo de las tres, porque cada una se cierra por una razon distinta.
Intent: el vocabulario de entrada
Cierra lo que el usuario puede pedir. Su exhaustividad garantiza que el reducer trate todos los gestos posibles y que añadir uno nuevo produzca un error de compilacion en el sitio exacto donde falta la decision.
Efecto: el vocabulario de salida
Cierra lo que ocurre una sola vez y no se guarda. Su exhaustividad garantiza que el colector de la vista maneje todos los sucesos y que ninguno quede sin destino al añadirse.
Contenido: el vocabulario de la pantalla
Cierra las situaciones excluyentes en que la pantalla puede encontrarse. Su exhaustividad garantiza que el render cubra todos los casos y que no exista combinacion sin dibujo asociado.
Un intent llamado MostrarDialogo ya ha decidido la respuesta: describe una orden a la vista, no un hecho del usuario. Llamado TareaPulsadaLargamente, describe lo ocurrido y deja que el reducer decida si eso abre un dialogo, ignora el gesto o hace otra cosa segun el estado. La diferencia parece de estilo y es de acoplamiento: los nombres en pasado mantienen a la vista ignorante de la logica, permiten que la misma intencion produzca resultados distintos en contextos distintos, y hacen que el historial de intents se lea como una cronica de la sesion en vez de como una lista de ordenes ya resueltas.
Lo que de verdad se compra al sellar una jerarquia no es elegancia sintactica: es una redistribucion del trabajo cognitivo entre la persona y la maquina. Piensa en el problema real del mantenimiento de una aplicacion viva. Un dia añades un caso nuevo al dominio —un estado de sesion expirada, una accion nueva, un tipo de error que antes no existia— y con ese gesto minimo has creado una obligacion difusa: existen ahora en tu codigo un numero indeterminado de lugares que tratan ese dominio y que acaban de quedar incompletos. Sin cierre, esa obligacion recae integramente sobre la memoria humana; hay que buscar, hay que recordar, hay que confiar en que la busqueda textual encontro todos los sitios y en que quien revise el cambio conozca el codigo lo suficiente. Es un trabajo que se hace mal no por descuido sino por naturaleza: nadie tiene en la cabeza el mapa completo de un sistema de cierto tamaño, y los sitios olvidados no se manifiestan en el momento del olvido sino meses despues, en un dispositivo ajeno, en la rama rara. Con la jerarquia sellada y el when sin else, esa misma obligacion se convierte en una lista de errores de compilacion que aparece antes incluso de que termines de escribir. La pregunta “¿me habre dejado algo?” deja de tener sentido, porque el compilador ya la respondio y su respuesta es exhaustiva por construccion. Ese es el patron profundo que se repite en todo el diseño con tipos y que conviene reconocer una vez para verlo despues en todas partes: los tipos no estan ahi para documentar lo que ya sabes, sino para hacer que ciertos errores dejen de ser posibles y que ciertas tareas dejen de depender de que alguien se acuerde. Cada else que escribes sobre un tipo sellado es una renuncia voluntaria a esa garantia, un pequeño prestamo que se paga siempre, y siempre tarde.
- Escribe el
sealed interfacede intents de una pantalla tuya nombrando cada caso por el hecho ocurrido y no por la reaccion deseada; justifica cuales sondata objecty cualesdata class. - Separa en una segunda jerarquia los efectos de una sola vez y argumenta, para cada uno, por qué guardarlo en el estado produciria un bug al rotar la pantalla.
- Busca en tu codigo un
whenconelsesobre un tipo sellado, quitalo, añade un caso nuevo a la jerarquia y anota cuantos errores de compilacion aparecen: esa es la lista que antes tenias que recordar. - Toma un
enumque haya empezado a llevar campos opcionales para distinguir casos y conviertelo en jerarquia sellada, explicando qué combinaciones imposibles acabas de eliminar. - Diseña un caso que deba pertenecer a dos clasificaciones a la vez y razona por qué eso obliga a usar
sealed interfaceen lugar desealed class.