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”.
// 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:
// 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.
// Correcto: "Apoyo" pertenece al conjunto.
const rolActual: Rol = "Apoyo";
// Error: "Support" no es ninguno de los tres literales.
// const rolMal: Rol = "Support";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.
// 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.
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.
// 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.
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 narrowing —typeof, in, instanceof, funciones predicado— es el capítulo siguiente.
Comprueba lo que sabes#
Pregunta 1 de 5
¿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.
Paso 2: Que esté pulido
- Creas un type alias Rol con los tres valores válidos como tipos literales.
- Añades una tercera variante EventoCambioRol que usa Rol para el nuevo rol.
- Usas intersección (&) para añadir metainformación de auditoría (registradoPor, timestamp) a todas las variantes sin repetirla.
Paso 3: Que sea excelente
- La unión discriminada está diseñada para que un estado inválido sea imposible de construir: cada variante tiene exactamente los campos que le corresponden.
- Los campos de auditoría se heredan via extends en lugar de repetirse con &, y todos son readonly.
- Acceder a un campo de una variante equivocada es un error de compilación. Cero any.
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.
// Solución mejor: tipos literales para los valores cerrados (roles, tipos de evento)
// e intersección para combinar la metainformación de auditoría con cada evento.
interface HeroeBase {
readonly id: number;
readonly nombre: string;
readonly rol: string;
}
interface Heroe extends HeroeBase {
partidas: number;
victorias: number;
}
// Tipo literal para los roles válidos del juego: solo estos tres existen.
// Asignar "Support" o "DPS" sería un error en tiempo de compilación.
type Rol = "Daño" | "Tanque" | "Apoyo";
// Metainformación de auditoría: quién registró el evento y cuándo.
// Todos los eventos la comparten.
interface AuditoriaEvento {
readonly registradoPor: string;
readonly timestamp: string;
}
// Fichaje: se incorpora un héroe al equipo.
interface EventoFichaje {
tipo: "fichaje";
heroe: Heroe;
fechaAlta: string;
}
// Baja: un héroe sale del equipo.
interface EventoBaja {
tipo: "baja";
idHeroe: number;
motivo: string;
}
// Cambio de rol: un héroe cambia de especialidad.
interface EventoCambioRol {
tipo: "cambio-rol";
idHeroe: number;
// El nuevo rol debe ser uno de los tres válidos: el type literal lo garantiza.
nuevoRol: Rol;
}
// Cada evento de la unión se combina (&) con la auditoría: todos llevan trazabilidad.
type EventoEquipo =
| (EventoFichaje & AuditoriaEvento)
| (EventoBaja & AuditoriaEvento)
| (EventoCambioRol & AuditoriaEvento);
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 },
];
const registro: EventoEquipo[] = [
{ tipo: "fichaje", heroe: equipo[0], fechaAlta: "2026-01-10", registradoPor: "admin", timestamp: "2026-01-10T09:00:00Z" },
{ tipo: "fichaje", heroe: equipo[1], fechaAlta: "2026-01-12", registradoPor: "admin", timestamp: "2026-01-12T10:30:00Z" },
{ tipo: "baja", idHeroe: 99, motivo: "inactividad", registradoPor: "admin", timestamp: "2026-01-15T08:00:00Z" },
{ tipo: "fichaje", heroe: equipo[2], fechaAlta: "2026-02-01", registradoPor: "admin", timestamp: "2026-02-01T11:00:00Z" },
{ tipo: "cambio-rol", idHeroe: 1, nuevoRol: "Tanque", registradoPor: "coach", timestamp: "2026-02-10T14:00:00Z" },
];
function procesarEvento(evento: EventoEquipo): void {
// El campo "tipo" es el discriminante: cada rama da acceso solo a sus campos propios.
if (evento.tipo === "fichaje") {
console.log("[FICHAJE] " + evento.heroe.nombre + " — " + evento.fechaAlta + " (" + evento.registradoPor + ")");
} else if (evento.tipo === "baja") {
console.log("[BAJA] id " + evento.idHeroe + " — " + evento.motivo + " (" + evento.registradoPor + ")");
} else {
console.log("[CAMBIO ROL] id " + evento.idHeroe + " → " + evento.nuevoRol + " (" + evento.registradoPor + ")");
}
}
registro.forEach(procesarEvento); Por qué es mejor que el anterior
- type Rol = "Daño" | "Tanque" | "Apoyo" cierra el dominio: asignar "Support" o "DPS" es un error en compilación, no un bug silencioso en producción.
- La intersección (EventoFichaje & AuditoriaEvento) añade los campos de auditoría a cada variante sin duplicar código. Cada objeto del registro debe tener registradoPor y timestamp.
- EventoCambioRol demuestra que la unión puede crecer: añadir una variante nueva solo requiere extender el type alias. El resto del código no cambia.
// Solución excelente: unión discriminada bien diseñada donde cada variante
// tiene exactamente los campos que le corresponden y ninguno más.
// Construir un evento con campos que no le pertenecen es imposible:
// TypeScript lo rechaza en el punto de creación.
interface HeroeBase {
readonly id: number;
readonly nombre: string;
readonly rol: string;
}
interface Heroe extends HeroeBase {
partidas: number;
victorias: number;
}
// Tipos literales para los dominios cerrados.
// El compilador rechaza cualquier valor fuera del conjunto.
type Rol = "Daño" | "Tanque" | "Apoyo";
type TipoEvento = "fichaje" | "baja" | "cambio-rol";
// Metainformación de auditoría compartida por todos los eventos.
interface AuditoriaEvento {
readonly registradoPor: string;
readonly timestamp: string;
}
// Cada variante es una interface con un campo "tipo" de valor literal único.
// Eso hace que la unión sea DISCRIMINADA: el campo "tipo" es la etiqueta
// que identifica de qué variante se trata sin ambigüedad.
// Un héroe entra en el equipo.
interface EventoFichaje extends AuditoriaEvento {
// El literal "fichaje" es el discriminante: solo esta variante lo tiene.
readonly tipo: "fichaje";
readonly heroe: Heroe;
readonly fechaAlta: string;
}
// Un héroe sale del equipo.
interface EventoBaja extends AuditoriaEvento {
readonly tipo: "baja";
readonly idHeroe: number;
readonly motivo: string;
}
// Un héroe cambia de especialidad.
interface EventoCambioRol extends AuditoriaEvento {
readonly tipo: "cambio-rol";
readonly idHeroe: number;
// nuevoRol debe ser un rol válido del juego: el tipo literal lo garantiza.
readonly nuevoRol: Rol;
}
// La unión de las tres variantes. Para que TypeScript la reconozca como
// discriminada, cada miembro debe tener el campo discriminante ("tipo")
// con un literal distinto: así lo hace aquí.
type EventoEquipo = EventoFichaje | EventoBaja | EventoCambioRol;
// Función auxiliar: devuelve el tipo del evento.
// Si en el futuro añades una variante a EventoEquipo y olvidas manejarla
// en procesarEvento, TypeScript te avisa porque el else quedaría con un
// tipo que el compilador no puede reducir a ninguna de las variantes conocidas.
function tipoEvento(evento: EventoEquipo): TipoEvento {
return evento.tipo;
}
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 type-checker garantiza que cada objeto del array es una variante válida
// de EventoEquipo y que tiene exactamente los campos que le corresponden.
// No puedes añadir "motivo" a un fichaje ni "heroe" a una baja.
const registro: EventoEquipo[] = [
{
tipo: "fichaje",
heroe: equipo[0],
fechaAlta: "2026-01-10",
registradoPor: "admin",
timestamp: "2026-01-10T09:00:00Z",
},
{
tipo: "fichaje",
heroe: equipo[1],
fechaAlta: "2026-01-12",
registradoPor: "admin",
timestamp: "2026-01-12T10:30:00Z",
},
{
tipo: "baja",
idHeroe: 99,
motivo: "inactividad",
registradoPor: "admin",
timestamp: "2026-01-15T08:00:00Z",
},
{
tipo: "fichaje",
heroe: equipo[2],
fechaAlta: "2026-02-01",
registradoPor: "admin",
timestamp: "2026-02-01T11:00:00Z",
},
{
tipo: "cambio-rol",
idHeroe: 1,
nuevoRol: "Tanque",
registradoPor: "coach",
timestamp: "2026-02-10T14:00:00Z",
},
];
// Al comparar evento.tipo, TypeScript estrecha el tipo dentro de cada rama:
// en la primera solo existen heroe y fechaAlta; en la segunda, idHeroe y motivo;
// en la tercera, idHeroe y nuevoRol. Intentar acceder a un campo de otra
// variante es un error de compilación.
function procesarEvento(evento: EventoEquipo): void {
if (evento.tipo === "fichaje") {
console.log(
"[FICHAJE] " +
evento.heroe.nombre +
" — " +
evento.fechaAlta +
" (por " +
evento.registradoPor +
")",
);
} else if (evento.tipo === "baja") {
console.log(
"[BAJA] id " +
evento.idHeroe +
" — " +
evento.motivo +
" (por " +
evento.registradoPor +
")",
);
} else {
console.log(
"[CAMBIO ROL] id " +
evento.idHeroe +
" -> " +
evento.nuevoRol +
" (por " +
evento.registradoPor +
")",
);
}
}
// Muestra el tipo de cada evento antes de procesarlo.
registro.forEach(function (evento) {
console.log("Tipo: " + tipoEvento(evento));
procesarEvento(evento);
}); Por qué es mejor que el anterior
- Cada variante extiende AuditoriaEvento (en lugar de combinar con &): la herencia expresa "un EventoFichaje ES un evento con auditoría", no solo "tiene esos campos también". La intención queda más clara.
- Todos los campos son readonly: una vez creado el evento, no se puede mutar. Cualquier intento de reasignación es un error de compilación.
- La función tipoEvento devuelve TipoEvento: prueba de que el compilador conoce el tipo del discriminante en todo momento. Si en el futuro añades una variante y olvidas manejarla en procesarEvento, TypeScript te avisa porque el else quedaría con un tipo que no puede reducir a ninguna de las variantes conocidas.