Una unión discriminada (también llamada tagged union) es una unión de tipos objeto que tienen en común una propiedad, la etiqueta, con un valor literal distinto en cada miembro. Comprobar la etiqueta le dice a TypeScript qué miembro tienes, y las demás propiedades de ese miembro pasan a estar disponibles.
Dentro de case "circle", leer shape.width sería un error de compilación, porque el miembro círculo no tiene width. La etiqueta son datos normales: en tiempo de ejecución es solo una propiedad string, y el switch es JavaScript normal.
Qué hace que una unión sea discriminada
El estrechamiento por la etiqueta funciona cuando se cumplen tres condiciones:
- Todos los miembros son tipos objeto.
- Todos los miembros tienen el mismo nombre de propiedad (el discriminante). El nombre lo eliges tú:
kind,type,statusytagson habituales. - En cada miembro, esa propiedad tiene un tipo literal: un literal string, number o boolean, o
null/undefined.
type UiEvent =
| { type: "click"; x: number; y: number }
| { type: "keypress"; key: string }
| { type: "scroll"; delta: number };
type ApiResponse =
| { ok: true; data: string } // boolean literal tags
| { ok: false; error: string };
type Message =
| { version: 1; text: string } // number literal tags
| { version: 2; text: string; lang: string };
Si un miembro declara la etiqueta como string a secas en lugar de un literal, comparar la etiqueta deja de estrechar la unión, y las propiedades específicas de cada miembro quedan fuera de alcance (TS2339).
Estrechar por la etiqueta
Cualquier comprobación sobre la etiqueta que TypeScript entienda estrecha el objeto: switch, if/else, === y !==, incluso una etiqueta desestructurada siempre que sea una const:
function describe(e: UiEvent): string {
if (e.type === "click") return `click at ${e.x},${e.y}`;
const { type } = e; // destructured tags narrow too
if (type === "keypress") return `key ${e.key}`;
return `scroll by ${e.delta}`; // only "scroll" is left
}
Comprobar una propiedad con "radius" in shape también estrecha, pero comparar una etiqueta se lee mejor y hace posible la comprobación exhaustiva.
Comprobaciones exhaustivas
La mayor ventaja del patrón aparece cuando la unión crece. Con un tipo de retorno explícito y sin default, TypeScript sabe que el switch debe cubrir todas las etiquetas, así que un miembro nuevo sin su case es un error de compilación:
index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
Ese mensaje no dice qué case falta. La función auxiliar assertNever sí, y además lanza un error en tiempo de ejecución si datos de fuera (JSON, un cliente antiguo) traen una etiqueta que según los tipos no puede existir:
Añade un quinto status sin su case y la línea assertNever(state) informa Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', nombrando el miembro. Hay más sobre esto en la página de never.
Hacer imposibles los estados imposibles
El RequestState de arriba sustituye a una forma habitual y más débil:
// Every combination is allowed, including nonsense
type LooseState<T> = {
loading: boolean;
data?: T;
error?: string;
};
const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };
Con campos opcionales, «cargando, con datos y con un error» pasa la comprobación de tipos, y cada lector tiene que adivinar qué combinaciones pueden darse de verdad. Con la unión discriminada, data solo existe en el estado success y error solo en error, así que el código que lee state.data tiene que demostrar antes que está en el estado success. El propio tipo documenta los estados válidos.
Modelar resultados: éxito o fallo
Una etiqueta booleana basta para «funcionó o no funcionó». Este tipo Result es una alternativa habitual a lanzar excepciones para fallos esperados, como una entrada inválida:
Quien llama no puede leer result.value sin comprobar antes result.ok, que es justo la comprobación que es fácil olvidar con excepciones o con retornos null.
Máquinas de estados y reducers
Dos uniones discriminadas, una para los estados y otra para las acciones, describen una máquina de estados. Un reducer hace switch sobre la acción y devuelve el siguiente estado; el compilador comprueba que cada objeto devuelto es un estado válido:
{ ...state, name: "paused" } compila solo porque state se estrechó antes al miembro playing, así que la copia lleva track y position. Devolver { name: "paused" } a secas sería un error: un estado en pausa necesita una pista. Es la misma forma que usan los reducers de Redux y useReducer en React.
La trampa del ensanchamiento
La etiqueta tiene que seguir siendo un tipo literal. En un objeto construido en una variable sin anotación, la etiqueta se ensancha a string, y entonces no encaja con ningún miembro:
type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;
const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.
const ok1: Shape = { kind: "circle", radius: 2 }; // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const; // or keep the literal with as const
area({ kind: "circle", radius: 2 }); // or build it where Shape is expected
La regla de fondo, que las propiedades mutables se ensanchan, se explica en la página de tipos literales.
Preguntas frecuentes
¿Qué es una unión discriminada en TypeScript?
Una unión de tipos objeto en la que todos los miembros tienen la misma propiedad (el discriminante o etiqueta) con un tipo literal distinto, por ejemplo { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Comprobar shape.kind === "circle" estrecha shape al miembro círculo, así que sus demás propiedades pasan a estar disponibles.
¿Qué diferencia hay entre una unión y una unión discriminada?
Una unión discriminada es una unión con una regla más: todos los miembros comparten una propiedad con un tipo literal único. Una unión normal como Cat | Fish se tiene que estrechar con in o con type guards propios; una unión discriminada se estrecha comparando una propiedad, y un switch sobre esa propiedad se puede comprobar de forma exhaustiva.
¿Cómo hago exhaustivo un switch sobre una unión discriminada?
O bien das a la función un tipo de retorno explícito y no pones default (entonces un case que falta es el error TS2366), o bien añades default: return assertNever(value) con function assertNever(x: never): never { throw ... }. La segunda forma nombra en el error el miembro sin manejar y además lanza un error en tiempo de ejecución si llegan datos inesperados.
¿Por qué mi unión discriminada no se estrecha?
La etiqueta tiene que ser un tipo literal en todos los miembros. Si un objeto se creó sin anotación, su kind se ensancha a string y ya no encaja con ningún miembro (TS2345/TS2322). Arréglalo con una anotación, con as const o creándolo donde se espera el tipo unión. Un miembro tipado kind: string también impide que la unión se estreche por kind.
¿Puede el discriminante ser un booleano o un número?
Sí. Funciona cualquier tipo literal: strings (kind: "circle"), números (version: 2), booleanos (ok: true / ok: false) e incluso null o undefined. Las etiquetas string son las más habituales porque se leen bien en los logs o cuando se envían como JSON.