Kotlin: sealed classes y when para el estado de UI sin librerías
El estado de una pantalla de Android es el caso donde el patrón se aplica hoy con más disciplina y menos herramientas: una jerarquía sellada describe los estados posibles, when exhaustivo obliga a cubrirlos todos y un flujo observable los publica hacia la composición. Esta lección construye ese circuito completo sin importar ninguna librería de máquinas, distingue con precisión lo que sealed garantiza —que la lista de estados es cerrada— de lo que no garantiza en absoluto —que las transiciones entre ellos sean legales— y muestra dónde hay que poner la función reductora para recuperar esa segunda mitad.
Si en Swift el tipo suma es una construcción del lenguaje, en Kotlin es una jerarquía de clases a la que se le ha cerrado la puerta: sealed declara que el conjunto de subtipos está completo en tiempo de compilación y que nadie fuera de tu módulo podrá añadir uno más. Esa promesa, aparentemente modesta, es la que permite al compilador razonar sobre exhaustividad y la que ha convertido el estado de UI en Android en el ejemplo canónico del patrón fuera de JavaScript. Lo notable del ecosistema Android no es que use máquinas de estado, porque casi nadie las llama así, sino que haya llegado a la mitad estructural del statechart por pura higiene de tipos, y que la mitad restante —la legalidad de las transiciones— siga siendo la carencia más habitual de los proyectos que creen tenerlo resuelto.
- Distinguir con precisión una jerarquía
sealedde unenumy saber cuándo cada una es la representación adecuada. - Aprovechar la exhaustividad de
whencomo expresión para que el compilador cubra todos los estados de la pantalla. - Montar el circuito unidireccional completo con un flujo de estado observable y una función reductora pura.
- Identificar la garantía que
sealedno da: nada impide asignar un estado ilegal desde el estado actual.
Sellado no es enumerado con adornos
La diferencia entre enum class y sealed interface se explica mal en casi toda la documentación, y sin embargo es la decisión de modelado más frecuente de este ecosistema. Un enum describe un conjunto fijo de instancias únicas, todas del mismo tipo y con la misma forma; sirve cuando los estados son etiquetas sin carga o cuando todos llevan exactamente los mismos campos. Una jerarquía sellada describe un conjunto fijo de subtipos, cada uno con su propia forma, y por eso es el equivalente real del tipo suma con valores asociados.
sealed interface EstadoPantalla {
data object Inicial : EstadoPantalla
data object Cargando : EstadoPantalla
data class Contenido(val perfil: Perfil, val refrescando: Boolean) : EstadoPantalla
data class Vacio(val motivo: MotivoVacio) : EstadoPantalla
data class Fallo(val causa: Throwable, val intentos: Int) : EstadoPantalla
}
Las decisiones de esta declaración son todas deliberadas. Se usa interface y no class porque no hay estado compartido que heredar y porque una interfaz sellada permite que un subtipo participe además en otras jerarquías, algo imposible con herencia simple. Se usa data object para los casos sin carga, que desde Kotlin 1.9 aporta toString legible y evita el ruido de un objeto anónimo en las trazas. Y el campo refrescando vive dentro de Contenido en lugar de flotar fuera, con lo que se elimina de golpe la combinación absurda de estar refrescando sin tener todavía nada que refrescar: el estado de recarga en segundo plano no es un estado hermano de tener contenido, es un matiz interno de tenerlo.
Hasta Kotlin 1.0 los subtipos de una clase sellada debían declararse dentro de sus llaves. Desde 1.1 pueden estar en el mismo fichero, desde 1.5 las interfaces selladas existen y los subtipos pueden repartirse por el mismo paquete y unidad de compilación, y desde 1.7 la comprobación de exhaustividad se aplica también a when usado como sentencia y no solo como expresión. La dirección del ensanchamiento importa para el modelado: hoy puedes partir una jerarquía grande en varios ficheros por dominio sin perder ni la exhaustividad ni la promesa de cierre.
when exhaustivo: el compilador cubre la pantalla
La contrapartida del cierre es que el compilador conoce la lista completa de subtipos y puede exigir que la cubras. Usado como expresión, when sobre un tipo sellado no compila si falta una rama, y ese error aparece exactamente en el sitio donde hay que tomar una decisión de producto: cómo se pinta el estado nuevo. En una función componible eso significa que ningún estado puede quedarse sin representación visual por descuido.
@Composable
fun PantallaPerfil(estado: EstadoPantalla, alEvento: (Evento) -> Unit) {
when (estado) {
EstadoPantalla.Inicial -> Marcador()
EstadoPantalla.Cargando -> Indicador()
is EstadoPantalla.Contenido -> Ficha(estado.perfil, estado.refrescando)
is EstadoPantalla.Vacio -> Aviso(estado.motivo)
is EstadoPantalla.Fallo -> Reintento(estado.intentos) { alEvento(Evento.Reintentar) }
}
}
El detalle que hace agradable este código no es la exhaustividad sino el contrato inteligente: tras comprobar is EstadoPantalla.Contenido, el compilador convierte el valor a ese subtipo dentro de la rama y estado.perfil es accesible sin ninguna conversión explícita ni ningún tipo opcional. La consecuencia práctica es que desaparecen los signos de admiración y las comprobaciones de nulidad que plagaban la versión con campos opcionales: no hay nada que comprobar porque el perfil solo existe donde tiene sentido.
| Representación | Lista de estados | Datos por estado | Exhaustividad | Coste |
|---|---|---|---|---|
| booleanos y opcionales | implícita | compartidos | ninguna | combinaciones ilegales |
enum class |
cerrada | idénticos | verificada | no admite carga distinta |
sealed interface |
cerrada | propios de cada caso | verificada | una clase por estado |
| clase abierta | abierta | propios de cada caso | imposible | nadie controla los subtipos |
La última fila es la trampa en la que caen los proyectos que empezaron sin sellar. Si la clase base es abierta, cualquier módulo puede añadir un subtipo, el compilador no puede afirmar nada sobre la lista y todo when necesita una rama else que se traga en silencio los casos futuros. Sellar no es una anotación cosmética: es la renuncia explícita a la extensibilidad a cambio de que la máquina razone por ti, que es exactamente el intercambio que interesa cuando lo que modelas es un dominio cerrado como los modos de una pantalla.
El circuito completo y lo que sealed no garantiza
Con los dos elementos anteriores, el circuito unidireccional se escribe sin dependencias. El modelo de vista mantiene el estado en un flujo mutable y expone su versión de solo lectura; la composición recolecta ese flujo con conciencia del ciclo de vida y emite eventos hacia arriba; y en medio hay una función reductora que es una máquina de estados con todas las letras aunque nadie la llame así.
Antes del reductor hay que sellar también la otra mitad, y es el paso que más proyectos se saltan. Si los eventos entran como llamadas sueltas a métodos del modelo de vista, no existe ningún tipo que enumere las entradas del sistema y la transición no puede ser una función total sobre el par. Declararlos como una segunda jerarquía sellada cuesta seis líneas y devuelve dos propiedades: la lista de cosas que pueden ocurrirle a esta pantalla queda escrita en un sitio, y el compilador vuelve a colaborar cuando esa lista crece.
sealed interface Evento {
data class Abrir(val id: PerfilId) : Evento
data object Refrescar : Evento
data object Reintentar : Evento
data class Llegaron(val perfil: Perfil) : Evento
data class Rompio(val causa: Throwable) : Evento
}
Fíjate en que los dos últimos eventos no los produce la persona sino el repositorio: son resultados que vuelven de un efecto y entran por el mismo canal que los gestos de la interfaz. Unificar ambos orígenes en un solo vocabulario es lo que hace que el reductor vea una única secuencia ordenada de entradas y que la respuesta a una petición no tenga ningún privilegio sobre un toque en la pantalla.
class PerfilViewModel(private val repo: Repositorio) : ViewModel() {
private val _estado = MutableStateFlow<EstadoPantalla>(EstadoPantalla.Inicial)
val estado: StateFlow<EstadoPantalla> = _estado.asStateFlow()
fun enviar(evento: Evento) {
val anterior = _estado.value
val siguiente = reducir(anterior, evento)
if (siguiente != anterior) _estado.value = siguiente
ejecutarEfecto(anterior, siguiente, evento)
}
}
fun reducir(estado: EstadoPantalla, evento: Evento): EstadoPantalla = when (estado) {
EstadoPantalla.Inicial, is EstadoPantalla.Vacio -> when (evento) {
is Evento.Abrir -> EstadoPantalla.Cargando
else -> estado
}
EstadoPantalla.Cargando -> when (evento) {
is Evento.Llegaron -> EstadoPantalla.Contenido(evento.perfil, refrescando = false)
is Evento.Rompio -> EstadoPantalla.Fallo(evento.causa, intentos = 1)
else -> estado
}
is EstadoPantalla.Contenido -> when (evento) {
is Evento.Refrescar -> estado.copy(refrescando = true)
is Evento.Llegaron -> EstadoPantalla.Contenido(evento.perfil, refrescando = false)
else -> estado
}
is EstadoPantalla.Fallo -> when (evento) {
is Evento.Reintentar -> if (estado.intentos < 3) EstadoPantalla.Cargando else estado
else -> estado
}
}
flowchart LR V[composicion] -->|evento| R[funcion reducir] R -->|estado nuevo| S[StateFlow observable] S -->|recomposicion| V R -.->|efecto| E[repositorio y red] E -.->|evento de resultado| R style R fill:#cba6f7,color:#11111b style S fill:#89dceb,color:#11111b
Ahora bien, hay que decir con claridad lo que este montaje no compra, porque es la ilusión más extendida del ecosistema. Sellar la jerarquía garantiza que la lista de estados es cerrada y que nadie olvida pintar uno; no garantiza absolutamente nada sobre las transiciones. Nada en el sistema de tipos impide que un rincón cualquiera del modelo de vista asigne al flujo un estado de contenido estando en fallo, o que dos corrutinas escriban estados incompatibles con medio milisegundo de diferencia. La legalidad del movimiento no vive en sealed, vive en la función reductora, y solo existe si esa función es el único camino que escribe en el flujo.
El fallo típico no es conceptual sino de conveniencia: una función del modelo de vista que asigna directamente al flujo mutable porque en ese caso concreto llamar al reductor parecía burocrático. A partir de la segunda vez que ocurre, la máquina deja de ser la fuente de verdad y se convierte en una sugerencia, y el grafo que creías tener ya no describe el comportamiento real. Dos medidas baratas lo evitan: mantener el flujo mutable estrictamente privado y hacer que todo camino de escritura pase por una única función de envío, y devolver desde el reductor no solo el estado nuevo sino la lista de efectos a ejecutar, para que ninguna rama tenga la tentación de hacer las dos cosas por su cuenta.
Si la jerarquía empieza a tener quince subtipos con prefijos que sugieren familias, lo que estás pidiendo a gritos es jerarquía real, y Kotlin la admite sin ningún truco: un subtipo sellado puede a su vez ser una interfaz sellada con sus propios subtipos, y when sigue exigiendo exhaustividad en cada nivel. Esa anidación reproduce el estado compuesto de Harel con una diferencia importante: las transiciones que cruzan niveles las escribes tú, y no hay ni acciones de entrada ni historia. En el momento en que necesites esas dos cosas y además regiones que avanzan a la vez, la balanza empieza a inclinarse hacia una librería de verdad.
Vale la pena mirar la trayectoria completa, porque explica por qué este patrón se siente inevitable aquí y opcional en otros sitios. La comunidad Android pasó de escribir la lógica en la actividad, donde el estado era un puñado de campos mutables y el sistema podía destruir el objeto en cualquier momento, a un modelo de vista con un único flujo observable de un tipo sellado que la composición recolecta y pinta. Nadie lo planteó como adoptar máquinas de estado: se planteó como sobrevivir a los cambios de configuración, evitar los estados imposibles y hacer que la interfaz fuese una función pura del estado. Y sin embargo el resultado es, pieza por pieza, la mitad estructural de un statechart: un conjunto finito y cerrado de estados exclusivos, cada uno con sus datos, y una vista que es una función total sobre ese conjunto. Lo que quedó fuera es la mitad dinámica, y su ausencia es tan sistemática que se ha vuelto invisible: casi ningún proyecto declara qué transiciones son legales, casi ninguno documenta el grafo, y la comprobación de que no se puede pasar de fallo a contenido sin volver a cargar existe, cuando existe, dentro de un when que nadie ha dibujado nunca. Por eso la pregunta útil al auditar un proyecto Android no es si usa máquinas de estado, sino esta otra, mucho más incómoda: dado el estado actual, ¿cuántos sitios del código pueden escribir el siguiente? Si la respuesta es más de uno, tienes tipos sellados sin máquina, que es un modelo de datos excelente y una garantía de comportamiento inexistente.
- Localiza una pantalla cuyo estado sean varios campos independientes y reescríbelo como una interfaz sellada donde cada dato viva en el subtipo que lo justifica.
- Elimina toda rama
elsede loswhenque pintan la pantalla y comprueba cuántos casos estaban cayendo en el comodín. - Cuenta cuántos lugares del modelo de vista escriben hoy en el flujo mutable; si son más de uno, encamínalos todos por una única función de envío.
- Extrae la transición a una función reductora libre y escribe tests que solo la llamen a ella, sin instrumentación ni componibles.
- Añade un subtipo nuevo a la jerarquía y anota qué errores de compilación aparecen y cuáles no: los que no aparecen son tu deuda de transiciones.
- Dibuja a mano el grafo que tu reductor implementa y compáralo con el que creías tener antes de leer el código.