Modelar el dominio: estados inválidos irrepresentables
Convertir combinaciones imposibles de booleanos en un tipo que no las admite: ceguera booleana, recuento de habitantes, refactorización a máquina de estados y los límites del enum.
La mayoría de los errores de estado no son fallos de lógica: son fallos de modelado que la lógica se ve obligada a parchear. Tres banderas booleanas admiten ocho configuraciones; si tu dominio solo tiene tres estados legítimos, has declarado cinco mentiras y has firmado el compromiso de defenderlas a mano para siempre. El objetivo de esta lección es eliminarlas del tipo, no vigilarlas en el código.
- Reconocer la ceguera booleana y el coste de los estados imposibles.
- Usar el recuento de habitantes como criterio objetivo de diseño.
- Refactorizar banderas dispersas a una máquina de estados con enums.
- Identificar cuándo un enum es la herramienta equivocada.
La ceguera de los booleanos
Este tipo aparece en casi todos los proyectos, y casi siempre por acumulación: alguien añadió una bandera, luego otra, y nadie volvió a mirar el conjunto.
struct VistaModelo {
var cargando: Bool = false
var datos: [Articulo] = []
var error: Error? = nil
var vacio: Bool = false
}
Lee lo que ese tipo permite. Permite estar cargando y tener error a la vez. Permite tener datos y estar vacío. Permite no estar cargando, no tener datos, no tener error y no estar vacío: un cuarto estado silencioso donde la interfaz no muestra nada y nadie sabe por qué. Cada método que consuma este modelo tendrá que decidir qué hacer ante combinaciones que su autor nunca pensó, y decidirá distinto en cada sitio.
El síntoma clínico se llama ceguera booleana: un Bool no dice qué significa. Al leer if vacio && !cargando hay que reconstruir mentalmente el contrato completo, porque el tipo Bool no aporta ni una pista sobre el dominio.
Escribir “invariante: si cargando es true, error debe ser nil” en un comentario no impide nada. Un comentario es una petición dirigida a futuros lectores humanos; una restricción es algo que el compilador rechaza. Cuando puedas elegir entre documentar una invariante y hacerla irrepresentable, elige siempre lo segundo.
Contar habitantes como técnica de diseño
Existe un criterio objetivo para saber si un tipo está bien modelado: contar sus habitantes y compararlos con los estados reales del dominio.
El modelo anterior tiene dos booleanos, un opcional y una lista. Ignorando el contenido de la lista y del error, ya son ocho combinaciones estructurales de banderas, de las cuales tienen sentido tres o cuatro. La proporción de estados válidos —la densidad de significado del tipo— es inferior a la mitad.
enum Estado {
case inicial
case cargando
case cargado(articulos: [Articulo]) // no vacio por construccion
case vacio
case fallo(Error)
}
Ahora la cuenta es exacta: cinco estados declarados, cinco estados posibles, densidad del cien por cien. No existe forma sintáctica de estar cargando y fallido a la vez, porque los casos de un enum son mutuamente excluyentes por construcción. La invariante ha dejado de ser un acuerdo social para convertirse en un teorema del sistema de tipos.
flowchart LR subgraph ANTES [Cuatro banderas sueltas] A1[8 combinaciones representables] A2[3 con sentido] A3[5 estados imposibles que hay que vigilar] end subgraph DESPUES [Un enum de cinco casos] B1[5 casos representables] B2[5 con sentido] B3[0 estados imposibles] end A1 --> B1 style A3 fill:#f38ba8,color:#11111b style B3 fill:#a6e3a1,color:#11111b
De banderas a máquina de estados
La refactorización tiene tres movimientos, y conviene hacerlos en orden.
El primero es agrupar: identificar qué variables solo tienen sentido juntas y meterlas en el payload del caso que las necesita. El error solo importa si hay fallo, así que vive dentro de fallo y desaparece del resto del universo.
El segundo es nombrar las transiciones: si los estados forman una máquina, declara explícitamente qué transiciones existen. El compilador comprueba la exhaustividad del switch, y así una transición olvidada es un error de compilación.
extension Estado {
mutating func aplicar(_ evento: Evento) {
switch (self, evento) {
case (.inicial, .pedir), (.fallo, .pedir):
self = .cargando
case (.cargando, .llegaron(let arts)) where arts.isEmpty:
self = .vacio
case (.cargando, .llegaron(let arts)):
self = .cargado(articulos: arts)
case (.cargando, .rompio(let e)):
self = .fallo(e)
default:
break // transiciones no validas: ignoradas
}
}
}
El tercero es empujar la validación hacia el constructor: si un caso exige una lista no vacía, que sea imposible construirlo con una lista vacía. Se consigue con un inicializador fallible y almacenando el valor ya validado.
struct ListaNoVacia<Elemento> {
let cabeza: Elemento
let resto: [Elemento]
init?(_ items: [Elemento]) {
guard let primero = items.first else { return nil }
cabeza = primero
resto = Array(items.dropFirst())
}
}
A partir de ese punto, ninguna función que reciba una ListaNoVacia necesita comprobar si está vacía. La comprobación se hizo una vez, en la frontera, y el tipo transporta la prueba durante el resto de su vida. Esta idea —validar al construir en lugar de comprobar al usar— es la que convierte el modelado en trabajo que se amortiza.
Cuando haces irrepresentable un estado inválido no estás escribiendo código más bonito: estás trasladando una obligación de prueba desde las personas hacia la máquina. En el modelo de banderas, la afirmación “nunca estaremos cargando y fallidos a la vez” es una hipótesis que solo se puede sostener revisando todas las rutas de mutación del programa, hoy y después de cada cambio futuro; su verificación es responsabilidad humana, tiene coste lineal en el tamaño del código y se degrada con cada persona nueva en el equipo. Con el enum, esa misma afirmación es un corolario inmediato de la definición del tipo: no hay nada que revisar porque no hay nada que pueda ir mal. El compilador pasa de corrector ortográfico a revisor de invariantes, y lo hace en cada compilación, sin cansarse y sin olvidar. Ese es el rendimiento compuesto del modelado con tipos suma: cada invariante que consigues expresar en el tipo es una clase entera de errores que tu equipo no volverá a discutir en ninguna revisión de código, ni a depurar en producción, ni a cubrir con pruebas defensivas. No has reducido los errores; has reducido el espacio donde pueden existir.
Cuándo el enum es la herramienta equivocada
El entusiasmo tiene un límite y conviene conocerlo antes de reescribir medio proyecto.
Un enum es un conjunto cerrado: solo su autor puede añadir casos. Si necesitas que terceros amplíen el conjunto —extensiones, complementos, tipos de nodo definidos por el cliente— el enum es una barrera, y lo correcto es un protocolo con implementaciones o un struct que conforme RawRepresentable sobre String.
También hay una señal de olor característica: si todos los casos comparten exactamente la misma forma de payload y todo switch sobre el enum hace lo mismo con cada rama, no tenías una suma sino un producto con una etiqueta. Un struct con un campo de clasificación modela eso mejor y no obliga a tocar N funciones cada vez que aparece un caso.
Usa enum
Estados mutuamente excluyentes, conjunto conocido y estable, y comportamiento distinto por caso.
Usa protocolo
El conjunto debe crecer desde fuera del módulo, o cada variante trae su propia implementación completa.
Usa struct
Todos los casos comparten forma y comportamiento, y solo cambia un dato de clasificación.
- Toma un tipo real de tu código con dos o más banderas booleanas. Cuenta sus habitantes estructurales y cuántos tienen sentido.
- Sustitúyelo por un enum con payloads y comprueba cuántas comprobaciones defensivas puedes borrar.
- Escribe la función de transición como
switchsobre la tupla de estado y evento, sindefault, y observa qué transiciones habías olvidado. - Implementa
ListaNoVaciay una función que calcule el máximo sin devolver un opcional. - Argumenta un caso concreto de tu dominio donde un protocolo sea preferible al enum, y explica qué garantía pierdes a cambio.