learning-front

Nivel 5 · TypeScript: JavaScript con red de seguridad

Uniones e intersecciones

Componer tipos: un valor que puede ser varias cosas o que es varias a la vez, y las uniones discriminadas que un campo común vuelve seguras de distinguir.

Hasta ahora has tipado valores sueltos (capítulo 1) y objetos con forma fija (capítulo 2). Pero los datos de un sistema real son más ricos: un campo puede aceptar varios tipos distintos, un conjunto de valores puede ser cerrado, un objeto puede ser la combinación de dos formas, y un array puede contener elementos que son “una cosa o la otra”.

Para eso existen las uniones y las intersecciones: dos operadores con los que componer tipos más expresivos a partir de los que ya tienes.

Unión A | B: un valor que puede ser de varios tipos#

El operador | crea una unión: el valor puede ser de cualquiera de los tipos indicados. Es la forma de decirle a TypeScript “aquí puede llegar esto o lo otro”.

typescript
// El id de un héroe puede llegar como número desde la BD
// o como texto desde una URL. La unión modela ese contrato real.
type IdHeroe = number | string;

Con este tipo, TypeScript acepta 1 y acepta "tracer-29", pero rechaza true o null. Tienes flexibilidad donde la necesitas y protección donde no.

Tipos literales: un valor concreto como tipo#

Un tipo literal usa un valor concreto —en lugar de la categoría string o number— como tipo. La unión de literales forma un conjunto cerrado:

typescript
// Solo estos tres valores son válidos. Cualquier otro es un error de compilación.
type Rol = "Daño" | "Tanque" | "Apoyo";

¿Por qué importa? Porque en el juego no existe el rol “Support” ni “DPS”. Si alguien escribe uno de esos valores, el error sale en compilación, no cuando el dato llega a producción. El tipo documenta el dominio y lo protege al mismo tiempo.

typescript
// Correcto: "Apoyo" pertenece al conjunto.
const rolActual: Rol = "Apoyo";

// Error: "Support" no es ninguno de los tres literales.
// const rolMal: Rol = "Support";

¿Y los enum? Por qué preferimos uniones de literales#

Quizá hayas visto otra forma de cerrar un conjunto de valores: el enum. Hace algo parecido a una unión de literales, pero es una construcción aparte:

typescript
// Un enum agrupa valores con nombre bajo un tipo.
enum Rol {
  Dano,
  Tanque,
  Apoyo,
}

En TypeScript moderno se prefiere la unión de literales ("Daño" | "Tanque" | "Apoyo") frente al enum, por dos motivos concretos. Primero: la unión es JavaScript puro —al compilar desaparece, como el resto de los tipos—, mientras que un enum genera código en el bundle final. Segundo: los valores de la unión son los strings reales que viajan por tu API ("Daño"), no un número o un símbolo que luego hay que traducir. Cuando además necesitas los valores en tiempo de ejecución, la alternativa moderna es un objeto con as const, que verás en el próximo capítulo. Por eso en este curso los conjuntos cerrados se modelan con uniones de literales.

Intersección A & B: un valor que es ambas formas a la vez#

Donde | dice “una cosa o la otra”, & dice “una cosa y la otra”: el resultado tiene todos los campos de ambos tipos. Se usa para combinar formas que modelan responsabilidades distintas.

typescript
// La identidad del héroe: quién es.
interface HeroeBase {
  readonly id: number;
  readonly nombre: string;
  readonly rol: string;
}

// Las estadísticas: cómo juega.
interface Stats {
  partidas: number;
  victorias: number;
}

// Un héroe completo: tiene que cumplir los dos contratos.
type HeroeConStats = HeroeBase & Stats;

Un objeto de tipo HeroeConStats necesita todos los campos de HeroeBase y todos los de Stats. Si falta uno solo, TypeScript protesta.

never: el tipo que no admite ningún valor#

Un tipo describe qué valores son válidos: boolean admite dos, Rol admite tres, string admite infinitos. En el extremo opuesto hay un tipo que no admite ninguno: never. Es el “tipo vacío” (en la jerga, el bottom type), el reverso del unknown del capítulo 1: donde unknown acepta cualquier valor, never no acepta ninguno.

¿Para qué sirve un tipo sin valores? Aparece de forma natural en dos sitios. El primero: es lo que devuelve una función que nunca termina de forma normal —la que siempre lanza, o la que entra en un bucle infinito—. No llega a devolver nada, así que su retorno es never:

typescript
// Esta función nunca devuelve: siempre lanza. Su tipo de retorno es never.
function fallar(motivo: string): never {
  throw new Error(motivo);
}

El segundo: es lo que queda al vaciar una unión de todos sus miembros. De ahí sale la propiedad que más usarás: never es el elemento neutro de la unión. Añadirlo no cambia nada, porque no aporta ningún valor:

typescript
// Añadir never a una unión es como sumar cero: el tipo resultante es el mismo.
type SoloRol = Rol | never; // SoloRol es exactamente Rol

Con la intersección pasa lo contrario: T & never es never, porque pedir “que sea T y además un valor imposible” no lo puede cumplir nada.

¿Y qué? Hoy parece un detalle de pizarra, pero la identidad T | never = T es la pieza con la que —en el nivel avanzado de TypeScript— los tipos “se filtran solos”: cuando construyas tipos que descartan miembros de una unión, los casos que sobran se vuelven never y desaparecen sin dejar rastro. Y mucho antes de eso, en el capítulo siguiente, never reaparece como la herramienta para que TypeScript te obligue a cubrir todas las variantes de una unión: la comprobación exhaustiva.

Uniones discriminadas: distinguir variantes con seguridad#

Una unión discriminada (también llamada tagged union) es una unión de objetos donde cada miembro tiene un campo con un tipo literal único. Ese campo —el discriminante— le permite a TypeScript saber con exactitud de qué variante se trata.

typescript
// Cada interface declara su "tipo" con un literal diferente.
interface EventoFichaje {
  // el discriminante: solo este miembro lleva el literal "fichaje"
  tipo: "fichaje";
  heroe: string;
  fechaAlta: string;
}

interface EventoBaja {
  // el discriminante: solo este miembro lleva el literal "baja"
  tipo: "baja";
  idHeroe: number;
  motivo: string;
}

type EventoEquipo = EventoFichaje | EventoBaja;

La clave: al comparar evento.tipo, TypeScript estrecha el tipo automáticamente. Dentro del bloque if (evento.tipo === "fichaje"), ya sabe que evento es un EventoFichaje y que existen heroe y fechaAlta. Intentar acceder a idHeroe ahí es un error de compilación.

typescript
function procesarEvento(evento: EventoEquipo): void {
  if (evento.tipo === "fichaje") {
    // Aquí TypeScript sabe que es EventoFichaje.
    console.log(evento.heroe + "" + evento.fechaAlta);
  } else {
    // Aquí TypeScript sabe que es EventoBaja.
    console.log("id " + evento.idHeroe + "" + evento.motivo);
  }
}

Al comparar el campo discriminante con ===, TypeScript descarta dentro del if las variantes que no encajan y deja solo la que coincide; eso es estrechar el tipo (narrowing), y se estudia a fondo en el capítulo siguiente.

Nota sobre lo que no hace falta todavía: estás comparando el campo discriminante directamente con ===. Eso es todo lo que se necesita aquí. El toolkit completo de narrowingtypeof, in, instanceof, funciones predicado— es el capítulo siguiente.

Comprueba lo que sabes#

Pregunta 1 de 6

¿Qué significa `type Id = number | string`?

Tu turno#

El registro de la temporada tiene fichajes y bajas, pero sin tipos: TypeScript no conoce la forma de cada evento. Define la unión, las interfaces con sus campos literales y el resto de anotaciones hasta que el panel quede vacío. Cuando lo tengas, despliega las soluciones y fíjate en el salto de un nivel al siguiente.

Ejercicio · en esta página

Modela los eventos del Team Builder

El registro de la temporada contiene fichajes y bajas, pero el fichero no tiene tipos: TypeScript no sabe qué forma tiene cada evento. Define las interfaces, la unión y los tipos literales necesarios hasta que el panel "Problemas" quede vacío y la consola muestre todos los eventos.

Paso 1: Que funcione

  • Defines EventoFichaje y EventoBaja como interfaces separadas con su campo "tipo" literal.
  • Unes ambas en EventoEquipo y anotas el array y el parámetro de procesarEvento.
  • El panel "Problemas" queda vacío y la consola muestra los cuatro eventos.
Ver soluciones
// Solución OK: unión básica que funciona y elimina todos los errores.
// EventoEquipo es la unión de dos interfaces; el campo "tipo" permite
// distinguirlas con un if. Suficiente para que el panel quede vacío.

interface HeroeBase {
  readonly id: number;
  readonly nombre: string;
  readonly rol: string;
}

interface Heroe extends HeroeBase {
  partidas: number;
  victorias: number;
}

// Interfaz para el evento de fichaje.
interface EventoFichaje {
  tipo: "fichaje";
  heroe: Heroe;
  fechaAlta: string;
}

// Interfaz para el evento de baja.
interface EventoBaja {
  tipo: "baja";
  idHeroe: number;
  motivo: string;
}

// La unión: un EventoEquipo es un fichaje O una baja.
type EventoEquipo = EventoFichaje | EventoBaja;

const equipo: Heroe[] = [
  { id: 1, nombre: "Tracer",    rol: "Daño",   partidas: 120, victorias: 78  },
  { id: 2, nombre: "Mercy",     rol: "Apoyo",  partidas: 200, victorias: 155 },
  { id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95,  victorias: 61  },
];

// El array queda anotado: TypeScript sabe que cada elemento es un EventoEquipo.
const registro: EventoEquipo[] = [
  { tipo: "fichaje", heroe: equipo[0], fechaAlta: "2026-01-10" },
  { tipo: "fichaje", heroe: equipo[1], fechaAlta: "2026-01-12" },
  { tipo: "baja",    idHeroe: 99,      motivo: "inactividad"   },
  { tipo: "fichaje", heroe: equipo[2], fechaAlta: "2026-02-01" },
];

// Comparar el discriminante "tipo" nos dirige al miembro correcto de la unión.
function procesarEvento(evento: EventoEquipo): void {
  if (evento.tipo === "fichaje") {
    // Aquí TypeScript sabe que evento es EventoFichaje.
    console.log("[FICHAJE] " + evento.heroe.nombre + "" + evento.fechaAlta);
  } else {
    // Aquí TypeScript sabe que evento es EventoBaja.
    console.log("[BAJA] id " + evento.idHeroe + "" + evento.motivo);
  }
}

registro.forEach(procesarEvento);

Por qué este nivel

  • Dos interfaces con su campo "tipo" literal y la unión EventoEquipo: el mínimo para que TypeScript distinga los dos casos y el panel quede vacío.
  • La comparación if (evento.tipo === "fichaje") es suficiente para que TypeScript sepa en qué variante está y dé acceso a sus campos específicos.
  • Su límite: no hay tipos literales para los roles (cualquier string pasaría), no hay una tercera variante y los objetos del registro son mutables.