TypeScript de Cero a Experto / Narrowing
Discriminated unions: el patrón más útil de TS
Si te quedas con UN patrón de todo TypeScript, que sea este. Las discriminated unions modelan "una de varias formas posibles" con una elegancia que hace el código a prueba de errores: cada variante lleva una etiqueta, y TS estrecha por ella con precisión total. Modelan estados, acciones, respuestas de API —está en el corazón de Redux, de los reducers de React, de todo TS profesional.
La etiqueta que lo dice todo
El ingrediente: un campo discriminante
Una discriminated union es una unión de objetos que COMPARTEN un campo con un tipo LITERAL distinto en cada uno —ese campo es el "discriminante":
// cada variante tiene 'tipo' con un literal ÚNICO: esa es la etiqueta
type Circulo = { tipo: "circulo"; radio: number };
type Rectangulo = { tipo: "rectangulo"; ancho: number; alto: number };
type Triangulo = { tipo: "triangulo"; base: number; altura: number };
type Figura = Circulo | Rectangulo | Triangulo;
function area(figura: Figura): number {
// el switch sobre el discriminante estrecha cada caso con precisión:
switch (figura.tipo) {
case "circulo":
return Math.PI * figura.radio ** 2; // 🔍 aquí figura es Circulo
case "rectangulo":
return figura.ancho * figura.alto; // 🔍 Rectangulo
case "triangulo":
return (figura.base * figura.altura) / 2; // 🔍 Triangulo
}
}
console.log(area({ tipo: "circulo", radio: 5 }).toFixed(2));
console.log(area({ tipo: "rectangulo", ancho: 4, alto: 3 }));Dentro de cada case, TS SABE la variante exacta: en case "circulo", figura
es un Circulo y te ofrece radio (pero NO ancho, que no tiene). El campo
tipo es la llave que abre cada caso. Comparado con el "radio" in figura de la
lección 2, esto es más robusto: no dependes de qué campo es único, sino de una
etiqueta explícita y diseñada para distinguir.
El caso que verás mil veces: estados de carga
El uso estrella. Toda app que pide datos tiene tres estados, y modelarlos como discriminated union hace IMPOSIBLES las combinaciones sin sentido:
type Estado =
| { estado: "cargando" }
| { estado: "exito"; datos: string[] }
| { estado: "error"; mensaje: string };
function pintar(s: Estado): string {
switch (s.estado) {
case "cargando": return "⏳ Cargando...";
case "exito": return "✓ " + s.datos.length + " resultados"; // datos existe AQUÍ
case "error": return "✗ " + s.mensaje; // mensaje existe AQUÍ
}
}
// lo poderoso: es IMPOSIBLE tener 'datos' sin estar en éxito, o 'mensaje'
// sin estar en error. El tipo prohíbe los estados imposibles por diseño.Fíjate en la garantía: datos SOLO existe cuando estado es "exito", y
mensaje SOLO cuando es "error". En JavaScript tendrías un objeto { cargando, datos, error } con todos los campos opcionales, y nada te impediría el absurdo
"cargando Y con error Y con datos a la vez". La discriminated union hace ese
absurdo IMPOSIBLE de escribir —el estado imposible ni siquiera compila.
Ese reducer es, literalmente, cómo funciona el manejo de estado en React con
useReducer y en Redux: las acciones son una discriminated union por su campo
tipo, y el switch estrecha cada una para acceder a sus datos con seguridad.
Aprender este patrón ahora es entender de raíz una de las arquitecturas más
usadas del frontend.
¿Qué hace a una unión ser 'discriminada' y por qué es mejor que estrechar con 'in'?
Mini-reto
Modela con etiquetas: 1) crea type Evento = { tipo: "click"; x: number; y: number } | { tipo: "tecla"; codigo: string } | { tipo: "scroll"; delta: number }; 2)
escribe describir(e: Evento): string con un switch sobre e.tipo que use los
campos propios de cada caso; 3) confirma que dentro de case "click" TS te ofrece
x/y pero NO codigo. Acabas de modelar eventos como los modela cualquier
sistema serio: imposibles de confundir.
Qué sigue
Tu switch sobre el discriminante cubre todos los casos… ¿pero qué pasa el día
que agregues una variante nueva y olvides su case? La última lección del módulo
te da un guardián automático para eso: la exhaustividad con never, que rompe la
compilación hasta que manejes TODOS los casos. Tu red, completa.