wandres.dev
MÁQUINAS FINITAS · estados y transiciones

Estados imposibles fuera del modelo

El pecado original de la gestión de estado es modelar una situación con un puñado de banderas booleanas independientes: cuatro booleanos generan dieciséis combinaciones, de las que solo un puñado son legales, y las demás —isLoading y isError a la vez— son bombas de relojería. Una máquina de estados, respaldada por una unión discriminada en el tipo, colapsa esa explosión: en lugar de dieciséis combinaciones posibles y cuatro válidas, define cuatro estados y ninguno ilegal. Esta lección demuestra cómo se hace irrepresentable lo imposible, en el tipo y en tiempo de ejecución.

⏱ 17 min

Casi todo bug de interfaz que has visto tiene la misma forma: la pantalla muestra a la vez dos cosas que no pueden ser ciertas juntas. Un spinner girando sobre un mensaje de error. Un botón de guardar activo mientras ya se está guardando. Datos viejos visibles bajo un “cargando”. Ninguno de esos estados corresponde a una situación real del sistema; son artefactos de haberlo modelado con banderas booleanas sueltas, cada una encendiéndose y apagándose por su cuenta. La FSM ataca el problema de raíz: no intenta evitar que aparezcan los estados imposibles, hace que ni siquiera sean representables.

🎯 Al terminar esta lección sabrás
  • Reconocer la explosión combinatoria de 2^n que generan las banderas booleanas independientes.
  • Ver por qué el bug clásico isLoading y isError a la vez es un síntoma estructural, no un descuido.
  • Colapsar esa explosión con una unión discriminada que ata cada dato a su estado.
  • Combinar la unión (estados legales) con la máquina (transiciones legales) como dos capas de imposibilidad.

El producto cartesiano de booleanos

Modela una petición de datos con lo primero que sale: cuatro banderas. Parece razonable, y es una trampa.

type Peticion = {
  isLoading: boolean
  isError: boolean
  data: Usuario | null
  error: Error | null
}

Cuatro booleanos —o cuatro campos con dos estados cada uno— generan 2^4 = 16 combinaciones. Enuméralas y descubrirás que solo cuatro tienen sentido: reposo, cargando, éxito con datos, y fallo con error. Las otras doce son estados imposibles perfectamente representables: isLoading: true con isError: true, o data y error ambos rellenos, o cargando con datos viejos aún presentes. El tipo las admite todas por igual. Nada en Peticion dice que sean ilegales, así que tarde o temprano tu código produce una y la UI la renderiza.

El problema escala fatal: cada bandera nueva duplica el espacio. Cinco booleanos son 32 combinaciones; ocho, 256. El número de estados legales crece de forma lineal con la complejidad real del problema, pero el número de estados representables crece exponencialmente. Esa brecha entre lo representable y lo legal es exactamente el territorio donde viven los bugs.

⚠️
El bug clásico no es un descuido, es el modelo

Cuando ves isLoading && isError en producción, la reacción instintiva es “se me olvidó apagar una bandera”. Pero el fallo no está en el olvido: está en que el modelo PERMITE olvidarlo. Dos booleanos independientes no tienen forma de saber que son mutuamente excluyentes; nada en el tipo lo codifica. Añadir un if defensivo que apague isError al encender isLoading es parchear un síntoma mientras la causa —la representación— sigue intacta. El siguiente desarrollador, o tú en tres meses, encenderá otra combinación imposible por otra vía.

Hacer irrepresentable lo imposible

La técnica se popularizó con la charla de Richard Feldman “Making Impossible States Impossible” en el mundo de Elm, y hoy es idiomática en TypeScript vía uniones discriminadas. La idea: un único campo discriminante —llámalo status— nombra el estado, y cada variante lleva consigo, y solo consigo, los datos que en ese estado tienen sentido.

El nombre del discriminante es convención, no dogma: status, type, kind o tag cumplen igual, y lo único que importa es que sea un literal distinto por variante para que el compilador pueda estrechar. La misma idea llegó a React con el consejo de Kent C. Dodds de dejar de usar booleanos como isLoading y modelar un único campo de estado. No es una moda de un ecosistema concreto: es teoría de tipos algebraicos aplicada, y por eso reaparece en Elm, en Rust con sus enum, en Swift con sus enum de valores asociados y en Haskell con sus tipos suma. Cuando la ves una vez, la reconoces en todas partes.

type Peticion =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Usuario }
  | { status: "error"; error: Error }

Mira lo que desapareció. data solo existe cuando status es success; error solo existe cuando es error. Es imposible construir un valor con ambos, porque ninguna variante los declara juntos. Las dieciséis combinaciones colapsaron a cuatro estados, y los doce imposibles ya no se pueden ni escribir: el compilador los rechaza. Pasaste de “cuatro válidos entre dieciséis representables” a “cuatro representables, cuatro válidos”.

💡
El antipatrón de los campos opcionales

Existe un punto medio engañoso: conservar un solo objeto pero marcar los campos como opcionales —data?: Usuario, error?: Error—. Parece más limpio que cuatro booleanos, pero no cierra el agujero: el tipo sigue admitiendo data y error presentes a la vez, o ambos ausentes en un estado que debería tener uno. Los opcionales expresan “puede faltar”, no “existe exactamente en este estado y en ningún otro”. Solo el discriminante ata cada dato a su estado. Si te descubres sembrando interrogaciones por todo un objeto de estado, es la señal inequívoca de que necesitas una unión, no más opcionales.

El discriminante, además, desbloquea el estrechamiento por control de flujo. Dentro de una rama que comprobó status === "success", el compilador sabe que data está presente y error no existe. Y un switch exhaustivo se verifica en el tipo: si añades un estado y olvidas tratarlo, el caso default con la aserción a never enciende un error de compilación.

function render(p: Peticion): string {
  switch (p.status) {
    case "idle": return "esperando"
    case "loading": return "cargando"
    case "success": return `hola ${p.data.nombre}`   // data existe aqui
    case "error": return `fallo: ${p.error.message}`  // error existe aqui
    default:
      const _exhaustivo: never = p   // error de tipo si falta un caso
      return _exhaustivo
  }
}

Esa exhaustividad es, además, un superpoder al refactorizar. El día que añadas un quinto estado —cancelled, pongamos— el compilador recorrerá cada switch sobre status de toda la base de código y marcará en rojo los que no lo tratan. No hay que recordar dónde se consumía el estado: el tipo lleva la cuenta por ti. Añadir un estado deja de ser una excavación arqueológica por el proyecto y se convierte en seguir los errores del compilador hasta que se apagan uno a uno.

📝
Sumas contra productos: la aritmética de los tipos

Los tipos tienen aritmética, y ahí está la explicación exacta del fenómeno. Un objeto con campos independientes es un tipo PRODUCTO: su número de valores posibles es el producto de los de sus campos, y por eso cuatro booleanos dan 2 × 2 × 2 × 2. Una unión discriminada es un tipo SUMA: su número de valores es la SUMA de los de sus variantes, 1 + 1 + 1 + 1 en nuestro caso. Pasar de banderas a unión es, letra por letra, cambiar una multiplicación de posibilidades por una adición. Los estados imposibles son, ni más ni menos, el exceso que la multiplicación fabrica y que la suma jamás llega a generar. Modelar con sumas cuando las alternativas son excluyentes es la forma algebraica de decir “esto o aquello, nunca ambos”.

💣

Banderas: 2^n representables

n booleanos independientes dan 2 elevado a n combinaciones. La mayoría son basura que el tipo admite y la UI acaba mostrando.

🎯

Unión: n representables

Un discriminante y variantes con sus datos dejan exactamente los estados legales. Lo imposible no se puede ni construir.

never: exhaustividad

El caso por defecto asignado a never obliga al compilador a exigir un tratamiento por cada estado. Añadir uno y olvidarlo no compila.

Dos capas de imposibilidad: unión y máquina

La unión discriminada mata los estados ilegales, pero no dice nada sobre las transiciones. Con solo el tipo, nada impide saltar de idle directamente a success sin pasar por loading, o de error a success sin un reintento. La unión describe qué formas son legales; la máquina de estados describe qué caminos entre esas formas son legales. Son dos capas complementarias.

stateDiagram-v2
[*] --> idle
idle --> loading : FETCH
loading --> success : RESOLVE
loading --> error : REJECT
error --> loading : RETRY
success --> loading : REFRESH

La máquina afirma que a success solo se llega desde loading por RESOLVE, y a loading desde idle, error o success. Un evento RESOLVE disparado en estado idle no tiene transición: la máquina lo ignora en lugar de fabricar un estado inconsistente. Así, un estado imposible por combinación de datos lo bloquea la unión, y una secuencia imposible de eventos la bloquea la máquina. Entre las dos, el espacio de lo que tu código puede producir se reduce a lo que el dominio permite.

Queda una tercera frontera que ni el tipo ni la máquina cubren por sí solos: los datos que entran del mundo exterior —una respuesta de red, un localStorage, un parámetro de URL— llegan sin garantía de tipo. En 2026 la práctica es sellar ese límite con un esquema: Zod o ArkType parsean la entrada cruda y devuelven la unión discriminada ya estrechada, o fallan ruidosamente. Así la imposibilidad que el tipo promete en compilación se sostiene también en ejecución, justo en el punto donde los datos no confiables intentan colarse. Tipo, máquina y validación de frontera son las tres capas que, juntas, no dejan resquicio por donde nazca un estado inconsistente.

Enumerar bien no es solo quitar imposibles; es descubrir los legales que faltaban. La interfaz típica olvida que success con lista vacía es un estado distinto de success con datos —el estado vacío que nadie diseña hasta que un cliente lo reporta—. La unión te fuerza a decidirlo de forma explícita en lugar de deducirlo con un length === 0 disperso por la vista.

type Peticion =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "empty" }                        // exito sin resultados: un estado propio
  | { status: "success"; data: Usuario[] }     // exito con al menos un dato
  | { status: "error"; error: Error }
ℹ️
La vista se vuelve una función total del estado

Cuando el estado es una unión discriminada, la vista se escribe como una función total: para cada variante, un fragmento de interfaz, sin ramas colgando ni condiciones anidadas que se pisan. El switch del render es el reflejo exacto de la máquina en la capa visual. El bug de “pinté el spinner y también el error” desaparece no porque lo evites, sino porque la vista recibe UN estado y dibuja lo que a ese estado le corresponde, punto. Esa correspondencia uno a uno entre estados y pantallas es, además, lo que permite que diseño y desarrollo hablen por fin del mismo conjunto finito de cosas.

Nota la simetría con el nivel 8. Allí el objetivo era que ningún observador viera un valor intermedio inconsistente durante la propagación; aquí, que ningún tipo pueda siquiera expresar una combinación inconsistente en reposo. La reactividad glitch-free atacaba la inconsistencia en el tiempo; la máquina y la unión la atacan en el espacio de valores. Son la misma guerra contra la inconsistencia, librada en dos frentes distintos.

El objetivo no es evitar el error, es hacerlo inexpresable

Hay una diferencia abismal entre un sistema que evita los estados imposibles y uno donde los estados imposibles no existen. El primero depende de vigilancia: if defensivos, tests que cubren combinaciones, revisiones que atrapan la bandera que quedó encendida. Todo eso es humano y falla. El segundo delega la garantía en la estructura: si el tipo no puede representar isLoading y isError a la vez, ninguna cantidad de código descuidado producirá esa combinación, porque ni siquiera se puede escribir. Esta es la inversión mental que separa a quien colecciona parches de quien diseña modelos. No preguntes “cómo evito que ocurra este estado malo”; pregunta “cómo hago que este estado malo sea impronunciable en mi tipo”. La unión discriminada colapsa el 2^n de combinaciones al n de estados reales, y la máquina colapsa el espacio de secuencias a las transiciones declaradas. Cuando ambas cosas están en su sitio, la clase entera de bugs de estado inconsistente no se arregla: se extingue, porque el universo donde vivía ya no forma parte de tu programa. Es el mismo principio del nivel 8 llevado al límite: no cures el glitch, haz que sea imposible por construcción.

⚔️ Extingue una clase de bugs
  1. Coge un componente tuyo con dos o más banderas booleanas de estado y cuenta cuántas combinaciones admite frente a cuántas son legales.
  2. Reescríbelo como unión discriminada con un campo status y comprueba que los datos cuelgan solo de la variante donde tienen sentido.
  3. Añade un switch exhaustivo con el caso never y verifica que añadir un estado sin tratarlo rompe la compilación.
  4. Dibuja la máquina que gobierna las transiciones entre esos estados y localiza qué saltos, hoy posibles, deberían estar prohibidos.
  5. Modela un semáforo con peatón: demuestra que la unión evita “verde para coche y peatón a la vez” sin un solo if.