Uniones discriminadas en TypeScript: estados sin errores imposibles

Un pedido puede estar pendiente, enviado, entregado o cancelado, y cada estado tiene datos distintos: la guía de envío solo existe si ya salió, la firma solo si se entregó. Con un objeto lleno de campos opcionales, nada impide un pedido «pendiente» con firma. Con las uniones discriminadas, esos estados imposibles ni siquiera se pueden escribir.

Un tipo por estado

Cada miembro de la unión lleva una propiedad común con un valor literal distinto, aquí estado. Esa propiedad es la que «discrimina»: mirándola, TypeScript sabe en qué variante estás.

// Los estados de un pedido: cada uno lleva solo los datos que tienen sentido en él.
type Pedido =
  | { estado: "pendiente"; creado: Date }
  | { estado: "enviado"; creado: Date; guia: string }
  | { estado: "entregado"; creado: Date; guia: string; firma: string }
  | { estado: "cancelado"; creado: Date; motivo: string };

function describir(p: Pedido): string {
  switch (p.estado) {
    case "pendiente":
      return "Pendiente de envío";
    case "enviado":
      return `En camino, guía ${p.guia}`;
    case "entregado":
      return `Entregado, firmó ${p.firma}`;
    case "cancelado":
      return `Cancelado: ${p.motivo}`;
  }
}

const pedidos: Pedido[] = [
  { estado: "pendiente", creado: new Date("2026-10-20") },
  { estado: "enviado", creado: new Date("2026-10-18"), guia: "MX-48213" },
  { estado: "entregado", creado: new Date("2026-10-15"), guia: "MX-47710", firma: "Ana López" },
  { estado: "cancelado", creado: new Date("2026-10-12"), motivo: "pago rechazado" },
];

for (const p of pedidos) console.log(describir(p));

Ábrelo en el editor y cámbialo →

Para comprobar los tipos, cada archivo se revisa con tsc, el compilador de TypeScript, y para ejecutarlo basta Node 24, que entiende .ts directamente:

npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es pedido.ts
node pedido.ts
salidaPendiente de envío
En camino, guía MX-48213
Entregado, firmó Ana López
Cancelado: pago rechazado

Dentro de cada case, TypeScript ha estrechado el tipo: en "enviado" sabe que existe p.guia, y en "cancelado", p.motivo. No hace falta ningún if (p.guia).

Una unión discriminada con cuatro estados de pedido y sus transicionesLos estados de un pedido: cada uno con sus propios datos"pendiente"creado"enviado"creadoguia"entregado"creadoguiafirma"cancelado"creadomotivoenviarentregarcancelarswitch (p.estado)En cada case,TypeScript sabequé estado esy qué campostiene.Si falta un case,no compila.La propiedad «estado» es la etiqueta que distingue a los cuatro.

Cuatro estados, cada uno con sus campos. La propiedad estado los distingue. Esquema: decodigo.com.

Lo que ya no se puede hacer

Fuera de una comprobación, pedir la guía es un error, porque el pedido podría estar pendiente:

type Pedido =
  | { estado: "pendiente"; creado: Date }
  | { estado: "enviado"; creado: Date; guia: string };

function guiaDe(p: Pedido): string {
  return p.guia; // ¿y si el pedido sigue pendiente?
}
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es acceso.ts
tscacceso.ts(6,12): error TS2339: La propiedad 'guia' no existe en el tipo 'Pedido'.
  La propiedad 'guia' no existe en el tipo '{ estado: "pendiente"; creado: Date; }'.

Un estado nuevo y un case olvidado

Lo más valioso llega cuando el programa crece. Añadimos el estado "devuelto" y no lo tratamos en el switch. Como la función promete devolver un string, TypeScript ve que hay un camino que no devuelve nada:

type Pedido =
  | { estado: "pendiente"; creado: Date }
  | { estado: "enviado"; creado: Date; guia: string }
  | { estado: "devuelto"; creado: Date; guia: string };

function describir(p: Pedido): string {
  switch (p.estado) {
    case "pendiente":
      return "Pendiente de envío";
    case "enviado":
      return `En camino, guía ${p.guia}`;
  }
}
tscfalta.ts(6,32): error TS2366: Falta la instrucción "return" final en la función y el tipo de valor devuelto no incluye 'undefined'.

El aviso es correcto, pero no dice qué estado falta. Para que lo diga, se añade un default que pasa el valor a una función que solo acepta never, el tipo que no tiene ningún valor. Si todos los casos están cubiertos, en el default no queda nada y p es never. Si falta uno, p es justamente ese estado y no encaja:

type Pedido =
  | { estado: "pendiente"; creado: Date }
  | { estado: "enviado"; creado: Date; guia: string }
  | { estado: "devuelto"; creado: Date; guia: string }; // estado nuevo

function noDeberiaPasar(x: never): never {
  throw new Error(`Estado sin tratar: ${JSON.stringify(x)}`);
}

function describir(p: Pedido): string {
  switch (p.estado) {
    case "pendiente":
      return "Pendiente de envío";
    case "enviado":
      return `En camino, guía ${p.guia}`;
    default:
      return noDeberiaPasar(p);
  }
}

Ábrelo en el editor y cámbialo →

npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es exhaustivo.ts
tscexhaustivo.ts(17,29): error TS2345: No se puede asignar un argumento de tipo "{ estado: "devuelto"; creado: Date; guia: string; }" al parámetro de tipo "never".

El mensaje nombra el estado olvidado. Y si a pesar de todo llega un valor inesperado al ejecutar, por ejemplo desde una API que cambió, la función lanza un error claro en vez de seguir con datos raros.

Transiciones que solo aceptan el estado correcto

Extract saca de la unión las variantes que encajan con un patrón. Con él, enviar solo acepta pedidos pendientes y entregar, solo enviados:

type Pedido =
  | { estado: "pendiente"; creado: Date }
  | { estado: "enviado"; creado: Date; guia: string }
  | { estado: "entregado"; creado: Date; guia: string; firma: string };

// Solo se puede enviar un pedido pendiente, y solo entregar uno enviado.
function enviar(p: Extract<Pedido, { estado: "pendiente" }>, guia: string): Pedido {
  return { estado: "enviado", creado: p.creado, guia };
}

function entregar(p: Extract<Pedido, { estado: "enviado" }>, firma: string): Pedido {
  return { ...p, estado: "entregado", firma };
}

const nuevo: Pedido = { estado: "pendiente", creado: new Date("2026-10-20") };
if (nuevo.estado === "pendiente") {
  const enCamino = enviar(nuevo, "MX-48213");
  console.log(enCamino);
  entregar(nuevo, "Ana López"); // error: todavía no se ha enviado
}

Ábrelo en el editor y cámbialo →

npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es transicion.ts
tsctransicion.ts(19,12): error TS2741: La propiedad "guia" falta en el tipo "{ estado: "pendiente"; creado: Date; }", pero es obligatoria en el tipo "{ estado: "enviado"; creado: Date; guia: string; }".

Entregar un pedido que no se ha enviado es un error de compilación, no un fallo que alguien descubre en producción.

Con campos opcionalesCon una unión discriminada
{ estado: string; guia?: string; firma?: string }Un tipo por estado, con sus campos obligatorios
Se puede crear un pedido «pendiente» con firmaEse objeto no compila
Hay que comprobar if (p.guia) en todas partesEl switch sobre estado ya sabe qué hay
Un estado nuevo pasa desapercibidonever señala cada switch sin actualizar

Todo el código de esta página se comprobó con TypeScript 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. Documentación oficial: Discriminated unions en el manual de TypeScript.