Modelar estados: hacer imposible lo inválido
Sustituir banderas booleanas y campos anulables por un tipo suma, contar cuántos estados representa de verdad una clase, y llevar al sistema de tipos las invariantes que hoy vigilan las validaciones en tiempo de ejecución.
Casi todos los modelos de estado nacen igual: una bandera booleana que parecía suficiente, luego un campo anulable para el error, luego otro para los datos, y al final una clase donde la mayoría de las combinaciones posibles no significan nada y solo un comentario impide que alguien las construya. La alternativa no consiste en validar mejor, sino en elegir una forma en la que esas combinaciones ni siquiera se puedan escribir. Ese es el trabajo real de una jerarquía sellada: no describir el dominio, sino recortarlo hasta que solo quepan los valores legítimos.
- Contar cuántos estados representa realmente una clase y cuántos de ellos son válidos.
- Distinguir el tipo producto de la clase del tipo suma de la jerarquía sellada.
- Refactorizar banderas y campos anulables a un caso por estado con solo sus datos.
- Trasladar invariantes de las comprobaciones en runtime al sistema de tipos.
Contar los estados que de verdad existen
Una clase con propiedades es un tipo producto: el número de valores posibles es el producto de los valores de cada campo. Esa multiplicación es la que se descontrola sin que nadie la vea.
// cuantos estados representa esto realmente
data class PantallaState(
val cargando: Boolean,
val datos: List<Articulo>?,
val error: String?,
val vacio: Boolean,
)
Dos booleanos dan cuatro combinaciones; dos campos anulables multiplican por dos cada uno; el total supera la treintena si cuentas la lista vacía como caso distinto. De todos ellos, los estados legítimos del dominio son tres o cuatro. El resto son absurdos que el tipo permite construir: cargando con error, datos presentes y error a la vez, ni cargando ni datos ni error. Nada en la firma impide escribirlos, así que tarde o temprano alguien los escribe, normalmente en una rama de recuperación de fallos que nadie prueba.
El síntoma clínico aparece enseguida en el código que consume ese tipo: condiciones encadenadas cuyo orden importa, comentarios que explican qué combinaciones son imposibles, y require o check en el init que validan en tiempo de ejecución lo que el tipo no supo prohibir.
Del producto a la suma
Un tipo suma dice “es exactamente uno de estos casos”, y cada caso lleva solo los datos que ese caso necesita. La suma reemplaza a la multiplicación y el recuento se desploma.
sealed interface PantallaState {
data object Cargando : PantallaState
data object Vacio : PantallaState
data class Contenido(val datos: List<Articulo>) : PantallaState
data class Fallo(val causa: Throwable, val reintentable: Boolean) : PantallaState
}
Cuatro estados, exactamente los cuatro del dominio, ni uno más. Contenido no tiene ningún campo de error porque no existe un contenido con error. Fallo no tiene datos anulables porque no hay datos que mostrar. Y la lista dentro de Contenido ya no es anulable: si hay contenido, hay lista, y el compilador lo garantiza en cada uso sin una sola comprobación.
flowchart TD A[Tipo producto: una clase con banderas y nulos] --> B[Estados posibles se multiplican] B --> C[La mayoria son combinaciones sin sentido] C --> D[Validaciones en runtime y comentarios] A2[Tipo suma: jerarquia sellada] --> E[Un caso por estado real] E --> F[Cada caso lleva solo sus datos] F --> G[El estado invalido no se puede escribir] style D fill:#f38ba8,color:#11111b style G fill:#a6e3a1,color:#11111b
Consumir el estado sin defensas
El cambio no solo limpia la declaración: cambia por completo el código que la usa. Con el tipo producto, cada consumidor tenía que reconstruir por su cuenta qué combinación significaba qué, y cada uno lo hacía a su manera.
// antes: la logica de interpretacion vivia en cada consumidor
if (state.error != null) mostrarError(state.error)
else if (state.cargando) mostrarSpinner()
else if (state.datos != null && state.datos.isNotEmpty()) mostrar(state.datos)
else mostrarVacio()
Ese bloque tiene una precedencia implícita, es sensible al orden, y nada garantiza que el siguiente consumidor lo escriba igual. Con el tipo suma, la interpretación ya está hecha en el propio tipo.
// despues: una rama por estado, sin orden implicito y sin nulos
when (state) {
Cargando -> mostrarSpinner()
Vacio -> mostrarVacio()
is Contenido -> mostrar(state.datos)
is Fallo -> mostrarError(state.causa, state.reintentable)
}
Las ramas sin prefijo son posibles gracias a la resolución sensible al contexto mejorada en Kotlin 2.3: como el tipo esperado del sujeto se conoce, no hace falta repetir PantallaState en cada línea. El resultado se lee como la especificación del dominio, y si mañana aparece un quinto estado el compilador visitará contigo cada consumidor.
Hay un exceso simétrico contra el que conviene avisar: repetir en todos los casos un dato que en realidad es común. Si el identificador de la sesión o el filtro activo existen en los cuatro estados, no pertenecen a ninguno de ellos, sino a un nivel superior. La forma correcta entonces es una clase envolvente con el dato compartido y una propiedad del tipo sellado para la parte que sí varía.
data class Pantalla(
val filtro: Filtro, // comun a todos los estados
val contenido: PantallaState, // la parte que varia por caso
)
Producto por fuera para lo compartido, suma por dentro para lo excluyente: casi todos los modelos reales son esa combinación, y equivocarse de nivel produce o duplicación en cada caso o nulos de vuelta en la raíz.
Sospecha de cada bandera booleana
Dos booleanos son cuatro estados y rara vez los cuatro significan algo. Si dos banderas nunca pueden ser ciertas a la vez, son un caso cada una.
Un campo anulable es una pregunta sin respuesta
Si un campo solo tiene valor en algunos estados, pertenece a esos estados y no a la clase entera.
Los require del init delatan el modelo
Toda validación que comprueba coherencia entre campos es una invariante que el tipo podría haber garantizado gratis.
Las transiciones también son un tipo
Cuando los estados forman una máquina, modela también los eventos como jerarquía sellada y escribe la transición como un when sobre ambos.
Máquinas de estados y transiciones
Cuando el dominio tiene transiciones, el patrón se dobla: una jerarquía para los estados, otra para los eventos, y una función total que los combina.
sealed interface Evento {
data object Recargar : Evento
data class Recibido(val datos: List<Articulo>) : Evento
data class Roto(val causa: Throwable) : Evento
}
fun transicion(actual: PantallaState, evento: Evento): PantallaState =
when (evento) {
Recargar -> Cargando
is Recibido -> if (evento.datos.isEmpty()) Vacio else Contenido(evento.datos)
is Roto -> Fallo(evento.causa, reintentable = actual is Contenido)
}
Cuando ese estado tiene que cruzar una frontera (guardarse en disco, viajar por la red, sobrevivir a la muerte del proceso), la jerarquía sellada sigue siendo la forma correcta: los serializadores modernos representan un tipo suma añadiendo un discriminador con el nombre del caso, de modo que el formato refleja la misma estructura que el tipo y no vuelve a aplanarse en un objeto lleno de campos opcionales. Es exactamente lo contrario del error habitual de serializar la clase con banderas y reconstruir el estado adivinando qué combinación era.
La función es total: para cualquier par de entradas devuelve un estado válido, y no puede devolver nada más porque no existe nada más. Las transiciones ilegales se expresan devolviendo el estado actual sin cambios, de forma explícita y visible, en vez de quedar como un hueco silencioso en una cascada de condiciones.
El instinto que traemos de la programación orientada a objetos clásica empuja a añadir: si aparece un requisito nuevo, un campo nuevo; si hay una duda, un anulable por si acaso; si algo puede faltar, un valor por defecto. Cada una de esas adiciones parece inofensiva y todas hacen lo mismo, multiplicar el espacio de estados representables mientras el conjunto de estados legítimos apenas se mueve. La distancia entre esos dos números es, literalmente, la superficie de error de tu programa: cada combinación representable pero ilegítima es un bug esperando a que alguien la construya, y la única defensa que queda cuando el tipo la admite es la disciplina humana, es decir, ninguna defensa a escala de equipo y de años. Modelar bien consiste en la operación contraria y mucho menos intuitiva: restar hasta que representable y legítimo coincidan. Una jerarquía sellada es la herramienta de resta por excelencia porque sustituye multiplicación por adición, y con ella desaparecen de golpe tres cosas que solemos aceptar como inevitables: los comentarios que explican qué combinaciones no deben darse, las validaciones que las comprueban en runtime y las pruebas que verifican esas validaciones. Nada de eso hace falta cuando el estado inválido no se puede ni escribir. Hay un test mental que sirve para cualquier tipo que diseñes y que conviene aplicar antes de escribir la primera línea de lógica: enumera todos los valores que tu declaración permite construir y táchalos uno a uno preguntándote si significan algo en el dominio; si tachas más de los que dejas, no tienes un modelo, tienes un contenedor de campos con una convención encima. Y una convención es exactamente aquello que el sistema de tipos existía para no tener que recordar.
- Elige la clase de estado más grande de tu código y calcula cuántas combinaciones permite construir.
- Marca cuántas de esas combinaciones son legítimas; la diferencia es tu deuda de modelado.
- Reescríbela como jerarquía sellada con un caso por estado real y sin ningún campo anulable.
- Borra los
requireocheckque validaban coherencia entre campos y comprueba que ya no hacen falta. - Añade una jerarquía de eventos y escribe la función de transición total como un
whensinelse.