Uniones discriminadas: un tipo, no tres booleanos
La forma más extendida de modelar una petición —un booleano de carga, un campo de error y un campo de datos, los tres opcionales y los tres independientes— fabrica ocho combinaciones posibles de las que solo tres significan algo, y deja las otras cinco esperando a que alguien las produzca. Esta lección sustituye ese producto cartesiano por una suma: una unión discriminada donde cada miembro lleva un discriminante literal y exactamente la carga útil que ese caso necesita, ni un campo más. De ahí salen el estrechamiento automático, la exhaustividad comprobada con `never` y el refactor guiado por el compilador, que al añadir un estado nuevo enumera él solo cada lugar del código que debes tocar.
Casi todo el código de frontend del mundo modela una petición de la misma manera: un booleano que dice si está cargando, un campo que guarda el error y otro que guarda los datos. Tres variables independientes para describir una sola realidad que nunca tuvo tres piezas, sino tres formas. El precio de esa desalineación es aritmético y despiadado: tres campos con dos valores cada uno producen ocho combinaciones, y de esas ocho solo tres significan algo. Las otras cinco —cargando con error, datos y error a la vez, ni cargando ni datos ni error— son estados que tu producto no tiene pero tu tipo sí, y el compilador no puede advertirte de ellos porque, tal y como lo escribiste, son perfectamente legales. Esta lección corrige la raíz: dejar de multiplicar campos y empezar a sumar casos, para que el tipo tenga exactamente tantos habitantes como estados tiene la pantalla.
- Diagnosticar la explosión combinatoria que produce modelar un estado con varios campos independientes.
- Escribir una unión discriminada donde cada miembro declare su discriminante literal y su carga útil exacta.
- Aprovechar el estrechamiento por discriminante y comprobar la exhaustividad con el tipo
never. - Elegir el discriminante y decidir cuándo un matiz merece un miembro nuevo o un campo dentro de uno existente.
Tres booleanos son ocho mundos posibles
Empieza por el modelo que casi todos escribimos alguna vez, y míralo no como código sino como una afirmación sobre la realidad.
type EstadoPeticion = {
cargando: boolean;
error: string | null;
datos: Usuario | null;
};
Ese tipo afirma que existe un mundo en el que una petición está cargando y además ya falló, y otro en el que tiene datos y error simultáneamente, y otro más en el que no está cargando ni tiene datos ni tiene error, un limbo sin nombre que la pantalla no sabe pintar.
Tú sabes que esos mundos no existen. El compilador no lo sabe, porque nunca se lo dijiste: le diste tres interruptores sueltos y le pediste que respetara una correlación que solo vive en tu cabeza.
La consecuencia inmediata aparece en la vista, y es tan cotidiana que ha dejado de parecer un síntoma. Como el tipo no ordena los casos, el orden lo pone la cadena de condicionales, y ese orden se convierte en lógica de negocio no declarada.
Si preguntas primero por el error y luego por la carga, un reintento fallido enseña el error viejo mientras se recarga; si inviertes las dos preguntas, enseña el spinner y esconde el fallo. Las dos versiones compilan, ninguna documenta su decisión, y la diferencia entre ellas es una línea que cualquiera reordena sin enterarse de lo que acaba de cambiar.
El segundo síntoma es la contabilidad manual de la limpieza. Al lanzar una petición nueva hay que poner cargando a verdadero y, en el mismo gesto, borrar el error anterior y quizá los datos anteriores, y ese trío de asignaciones hay que repetirlo en cada sitio donde algo se relance.
Basta olvidar una para fabricar un estado imposible real, no teórico: datos de la consulta anterior conviviendo con el error de la actual. El bug no fue mutar mal; fue que el tipo permitiera la mutación incoherente.
Conviene medir el desajuste con precisión, porque el número lo explica todo. La cardinalidad de un tipo es cuántos valores distintos lo habitan, y aquí es el producto de sus campos: dos por dos por dos, ocho, si simplificamos error y datos a presente o ausente.
Los estados legítimos son tres. Cinco octavos de ese tipo son territorio sin significado, y todo el código defensivo que escribes sobre él existe únicamente para patrullar esa frontera que nunca debiste dibujar.
Producto: campos que se multiplican
Varios campos independientes en un mismo objeto multiplican sus posibilidades. Añadir un booleano no suma un estado: duplica el espacio entero.
Suma: casos que se enumeran
Una unión enumera formas alternativas y excluyentes. Añadir un caso suma exactamente uno, y el compilador te lleva a cada sitio afectado.
La unión discriminada: un tipo, cuatro formas
La corrección consiste en cambiar de operación: dejar de multiplicar campos y empezar a sumar casos. Un tipo unión enumera formas alternativas y mutuamente excluyentes, y se llama discriminada cuando todos sus miembros comparten un campo cuyo valor es un literal distinto en cada uno.
type Peticion<T> =
| { estado: "inactiva" }
| { estado: "cargando" }
| { estado: "error"; mensaje: string; codigo: number }
| { estado: "exito"; datos: T; recibidoEn: number };
Lee ese tipo como una afirmación y compáralo con el anterior. Ahora dice que una petición está en exactamente una de cuatro formas, que el mensaje de error solo existe cuando hubo error y que los datos solo existen cuando hubo éxito.
Ese “solo existe” no es documentación ni convención: es una regla que el compilador aplica. Escribir peticion.datos sin haber comprobado antes el discriminante deja de compilar, y con ello desaparece la clase entera de bugs en que se lee un campo que en ese momento no tenía por qué estar.
Fíjate en lo que también desaparece: los null. En el modelo de tres campos, error y datos eran opcionales porque a veces no aplicaban, y cada lectura arrastraba una comprobación de nulidad que en realidad preguntaba por el estado, no por el valor.
En la unión no hay nada opcional. Cada miembro lleva sus campos obligatorios porque en ese caso siempre están, y la pregunta se hace una sola vez, en el sitio correcto y con el nombre correcto.
Hay un beneficio menos visible y más valioso: la carga útil deja de ser uniforme. El miembro de error puede llevar un código y una marca de reintento; el de éxito, la marca de tiempo con la que decides si el dato caducó; el de carga, el identificador de la petición en vuelo para poder cancelarla.
Nada de eso cabía en el modelo plano sin añadir más campos opcionales y, con ellos, más combinaciones sin sentido. Aquí cada dato vive dentro del único estado que lo justifica, y su ciclo de vida coincide exactamente con el de su caso.
El discriminante tiene que ser de tipo literal para que el compilador pueda distinguir los miembros. Un boolean solo separa dos casos y no escala; un string sin acotar no separa ninguno, porque todos los miembros tendrían el mismo tipo en ese campo y el estrechamiento no ocurriría. Dos descuidos rompen el mecanismo sin dar la cara. El primero es declarar el discriminante opcional: en cuanto puede faltar, deja de particionar la unión. El segundo es construir el valor fuera del literal, por ejemplo tomándolo de una variable de tipo string, con lo que se ensancha a string y pierde su identidad; para evitarlo se fija con as const o se anota el objeto con el tipo de la unión al crearlo.
Estrechamiento, exhaustividad y refactor guiado
Con el discriminante en su sitio, el compilador estrecha el tipo dentro de cada rama. Preguntar por estado no es solo control de flujo: es una operación sobre tipos que, dentro de la rama, deja visible exactamente un miembro de la unión.
function render(p: Peticion<Usuario>): string {
switch (p.estado) {
case "inactiva":
return "Pulsa para cargar";
case "cargando":
return "Cargando...";
case "error":
return `Fallo ${p.codigo}: ${p.mensaje}`; // datos no existe aquí
case "exito":
return `Hola ${p.datos.nombre}`; // mensaje no existe aquí
default: {
const imposible: never = p; // exhaustividad comprobada
return imposible;
}
}
}
La rama por defecto merece atención, porque es donde el tipo pasa de describir a vigilar. Si has cubierto los cuatro casos, en ese punto el tipo de p es never, el tipo sin habitantes, y la asignación compila.
El día que añadas un quinto miembro a la unión sin tocar esta función, p dejará de ser never en el default y el compilador romperá la compilación ahí mismo. No es una comprobación cosmética: es un test de cobertura que se ejecuta en cada build, en cada lugar del proyecto que consuma la unión, sin que nadie tenga que acordarse de escribirlo.
Ese es el rasgo que convierte la técnica en una herramienta de mantenimiento y no solo de corrección. Añade el estado revalidando a la unión, compila, y el compilador te entregará la lista completa de sitios que necesitan una decisión.
Lo que en el modelo de tres booleanos habría sido una búsqueda por texto y una confianza en la memoria del equipo, aquí es una lista finita, exacta y verificada. El refactor deja de ser arqueología y pasa a ser una conversación con la máquina.
flowchart LR I[inactiva] -- pedir --> C[cargando] C -- ok --> E[exito con datos] C -- fallo --> X[error con mensaje y codigo] X -- reintentar --> C E -- refrescar --> C style I fill:#89b4fa,color:#11111b style C fill:#cba6f7,color:#11111b style E fill:#a6e3a1,color:#11111b style X fill:#f38ba8,color:#11111b
Diseñar la unión: cuántos casos y con qué nombre
Queda la parte de criterio, que es donde se separan quienes conocen la sintaxis de quienes saben modelar. La primera decisión es el nombre del discriminante: estado, tipo, tag o kind dan igual mientras sea el mismo en todo el proyecto, porque la consistencia es lo que permite escribir utilidades genéricas sobre cualquier unión propia.
La segunda decisión es más fina: si un matiz nuevo merece un miembro propio o un campo dentro de uno existente. La regla práctica es preguntarse si la pantalla necesita pintar algo distinto y si el matiz trae datos que los demás casos no tienen.
Tómalo con el caso real de la recarga en segundo plano, ese en el que ya hay datos antiguos y llega una petición nueva. Modelarlo como miembro aparte obliga a duplicar la carga útil de éxito y a repetir el pintado de la lista en dos ramas.
Modelarlo como un campo dentro del éxito —los datos siguen ahí, más una marca de que hay una revalidación en curso— mantiene un solo lugar donde se pinta la lista y añade un detalle de presentación encima. La segunda opción suele ganar, y el criterio que la elige es que ese matiz no cambia qué datos hay, solo cómo se anuncian.
El extremo contrario también existe y conviene nombrarlo. Un miembro por cada combinación imaginable produce uniones de doce casos donde ocho comparten la misma vista, y entonces la exhaustividad deja de ayudar y empieza a estorbar, porque cada switch se vuelve un muro de ramas idénticas.
La unión bien diseñada tiene tantos miembros como formas distintas de pantalla, ni una más. Modelar es exactamente eso: encontrar el número correcto de casos, no el máximo.
Cuando varias pantallas consumen la misma unión, evita repetir el switch en cada una. Escribe una función de plegado que reciba la petición y un objeto con una función por cada miembro, y devuelva lo que esa función produzca. Cada consumidor pasa sus cuatro funciones, el switch vive en un solo lugar y la comprobación de exhaustividad se hace una vez en la firma en lugar de repetirse a mano. Es el mismo patrón que usan las bibliotecas maduras de resultados y opcionales, y traslada la ventaja de la unión desde el punto donde se define hasta todos los puntos donde se consume.
Detrás de esta lección hay una única pregunta que deberías hacerte ante cualquier estado que diseñes: estas piezas de información, ¿coexisten o se excluyen? Si coexisten —el nombre y el correo de un usuario están los dos a la vez— tienes un producto y un objeto con campos es la forma correcta. Si se excluyen —o está cargando, o falló, o llegó— tienes una suma, y meterla en un objeto con campos independientes es un error de modelado que después pagas en condicionales defensivos, en limpiezas manuales que alguien olvidará y en estados imposibles que solo aparecen en producción. La unión discriminada no es una técnica de TypeScript: es la traducción al lenguaje de un hecho sobre tu dominio, y por eso su beneficio no se queda en el tipado. Al escribirla te obliga a enumerar en voz alta cuántos estados tiene de verdad la pantalla, y esa enumeración es la conversación de diseño que casi nunca se tiene. La prueba de que has acertado es cuantitativa y puedes hacerla hoy sobre tu propio código: cuenta los habitantes del tipo y cuenta los estados legítimos del producto. Mientras el primer número supere al segundo, la diferencia es el tamaño exacto del terreno donde viven tus próximos bugs, y la siguiente lección se dedica entera a llevar esa diferencia hasta cero.
- Busca en tu código un estado modelado con dos o más campos correlacionados y calcula su cardinalidad multiplicando las posibilidades de cada campo.
- Enumera los estados que tu producto admite de verdad y resta: la diferencia es el número de estados imposibles que hoy son representables.
- Reescribe ese estado como una unión discriminada, moviendo cada campo al único miembro donde tiene sentido y eliminando todos los opcionales.
- Sustituye la cadena de condicionales de la vista por un
switchsobre el discriminante y añade la rama por defecto con la asignación anever. - Añade un miembro nuevo a la unión sin tocar nada más, compila y anota cuántos lugares te señaló el compilador; ese número es lo que antes buscabas a mano.
- Elige un matiz de tu dominio, como la revalidación con datos antiguos, y argumenta por escrito si merece un miembro propio o un campo dentro de uno existente.