En esta página

Unión e intersección de tipos

12 min lectura TextoCap. 2 — Tipos compuestos

Unión e intersección de tipos

Los tipos de unión e intersección son dos de las herramientas más poderosas del sistema de tipos de TypeScript. Las uniones permiten que un valor sea de uno entre varios tipos posibles. Las intersecciones combinan múltiples tipos en uno que debe satisfacer todos a la vez. Juntas, habilitan patrones de modelado de dominio que hacen imposibles los estados inválidos.

Tipos de unión: el operador `|`

Un tipo de unión indica que un valor puede ser de uno u otro tipo. Se escribe con el operador | entre los tipos.

type StringONúmero = string | number;

function formatearValor(valor: string | number): string {
  return typeof valor === 'string'
    ? valor.toUpperCase()
    : valor.toFixed(2);
}

formatearValor('hola');   // 'HOLA'
formatearValor(3.14159);  // '3.14'

Las uniones no se limitan a dos tipos:

type Primitivo = string | number | boolean | null | undefined;
type Identificador = string | number;
type Respuesta = 'sí' | 'no' | 'quizás';

Narrowing: estrechar el tipo

Cuando tienes un valor de tipo unión, TypeScript solo te permite usar las propiedades y métodos que son comunes a todos los tipos de la unión. Para acceder a características específicas de un tipo, debes "estrechar" (narrow) el tipo:

function procesarInput(input: string | number | boolean): string {
  // Aquí input puede ser cualquiera de los tres
  // input.toUpperCase() // ❌ Error: number y boolean no tienen toUpperCase

  if (typeof input === 'string') {
    return input.toUpperCase(); // ✅ TypeScript sabe que es string aquí
  }
  if (typeof input === 'number') {
    return input.toLocaleString('es-ES'); // ✅ TypeScript sabe que es number aquí
  }
  // Aquí TypeScript sabe que input es boolean (los otros casos ya fueron manejados)
  return input ? 'Verdadero' : 'Falso';
}

Los mecanismos de narrowing disponibles:

// typeof: para primitivos
if (typeof x === 'string') { /* x es string */ }
if (typeof x === 'number') { /* x es number */ }

// instanceof: para clases
if (x instanceof Date) { /* x es Date */ }
if (x instanceof Error) { /* x es Error */ }

// in: para verificar propiedades de objetos
if ('email' in usuario) { /* usuario tiene propiedad 'email' */ }

// Comparación de igualdad
if (x === null) { /* x es null */ }
if (x !== undefined) { /* x no es undefined */ }

// Truthiness check
if (x) { /* x es truthy: descarta null, undefined, 0, '', false */ }

Tipos literales: valores exactos como tipos

Los tipos literales son tipos cuyo valor es exactamente uno específico. En lugar de string, puedes tener el tipo "activo".

type DirecciónViento = 'norte' | 'sur' | 'este' | 'oeste';
type CódigoHTTP = 200 | 201 | 400 | 401 | 403 | 404 | 500;
type Bandera = true | false; // Equivalente a boolean

function moverPieza(dirección: DirecciónViento): void {
  console.log(`Moviendo hacia el ${dirección}`);
}

moverPieza('norte');    // ✅
moverPieza('nordeste'); // ❌ Error: no es un valor válido de DirecciónViento

Los literales ofrecen autocompletado en el editor: cuando escribes moverPieza(, el IDE sugiere exactamente los cuatro valores válidos.

Const assertion: preservar literales

Cuando TypeScript infiere el tipo de una variable, amplía los literales a sus tipos base por defecto:

const dirección = 'norte'; // tipo: 'norte' (literal, porque es const)
let dirección2 = 'norte';  // tipo: string (ampliado, porque es let)

const config = { dirección: 'norte' }; // { dirección: string } — ¡NO literal!

// Con const assertion, todo se convierte en literal y readonly:
const config2 = { dirección: 'norte' } as const;
// tipo: { readonly dirección: 'norte' }

as const es especialmente útil para arrays de constantes:

const ROLES = ['admin', 'editor', 'lector'] as const;
// tipo: readonly ['admin', 'editor', 'lector']

type Rol = typeof ROLES[number]; // 'admin' | 'editor' | 'lector'

Discriminated unions: el patrón más poderoso

Un discriminated union (también llamado tagged union o union de suma) es una unión de objetos donde cada miembro tiene una propiedad común con un valor literal único que TypeScript usa para distinguirlos.

// La propiedad 'tipo' es el discriminante
interface EstadoCargando {
  tipo: 'cargando';
}

interface EstadoExitoso<T> {
  tipo: 'exitoso';
  datos: T;
}

interface EstadoError {
  tipo: 'error';
  mensaje: string;
  código?: number;
}

type EstadoPetición<T> = EstadoCargando | EstadoExitoso<T> | EstadoError;

Con este patrón, TypeScript estrecha automáticamente el tipo dentro de cada rama:

interface Usuario {
  id: string;
  nombre: string;
}

function renderizarUsuario(estado: EstadoPetición<Usuario>): string {
  switch (estado.tipo) {
    case 'cargando':
      return '<div>Cargando...</div>';
    case 'exitoso':
      // TypeScript sabe que estado es EstadoExitoso<Usuario>
      return `<div>Bienvenido, ${estado.datos.nombre}</div>`;
    case 'error':
      // TypeScript sabe que estado es EstadoError
      return `<div>Error: ${estado.mensaje}</div>`;
  }
}

Modelar estados imposibles como tipos imposibles

El poder real de los discriminated unions está en que hacen que los estados inválidos sean irrepresentables:

// ❌ Mal diseño: múltiples props opcionales generan combinaciones inválidas
interface PeticiónProblemática {
  cargando: boolean;
  datos?: Usuario;
  error?: string;
  // ¿Qué pasa si cargando=true y datos también está definido?
  // ¿Y si cargando=false y ni datos ni error están presentes?
}

// ✅ Buen diseño: solo los estados válidos son posibles
type Petición<T> =
  | { estado: 'idle' }
  | { estado: 'cargando' }
  | { estado: 'exitoso'; datos: T }
  | { estado: 'error'; error: string };

Verificación exhaustiva con `never`

Cuando tienes un discriminated union en un switch, puedes usar never para garantizar que todos los casos están manejados. Si añades un nuevo tipo a la unión sin actualizar el switch, TypeScript reportará un error de compilación.

type Forma =
  | { tipo: 'círculo'; radio: number }
  | { tipo: 'cuadrado'; lado: number }
  | { tipo: 'rectángulo'; ancho: number; alto: number };

function calcularÁrea(forma: Forma): number {
  switch (forma.tipo) {
    case 'círculo':
      return Math.PI * forma.radio ** 2;
    case 'cuadrado':
      return forma.lado ** 2;
    case 'rectángulo':
      return forma.ancho * forma.alto;
    default: {
      // Si olvidamos un caso, TypeScript dice:
      // "Type 'X' is not assignable to type 'never'"
      const _verificaciónExhaustiva: never = forma;
      throw new Error(`Forma no manejada: ${JSON.stringify(_verificaciónExhaustiva)}`);
    }
  }
}

Si mañana añades | { tipo: 'triángulo'; base: number; altura: number } al tipo Forma sin actualizar calcularÁrea, el compilador te lo notificará inmediatamente.

Una alternativa más limpia usando una función auxiliar:

function verificarExhaustivo(valor: never, mensaje?: string): never {
  throw new Error(mensaje ?? `Valor no manejado: ${JSON.stringify(valor)}`);
}

function calcularÁrea(forma: Forma): number {
  switch (forma.tipo) {
    case 'círculo': return Math.PI * forma.radio ** 2;
    case 'cuadrado': return forma.lado ** 2;
    case 'rectángulo': return forma.ancho * forma.alto;
    default: return verificarExhaustivo(forma);
  }
}

Tipos de intersección: el operador `&`

La intersección combina múltiples tipos en uno que debe satisfacer todos simultáneamente.

type ConId = { id: string };
type ConTimestamps = { creadoEn: Date; actualizadoEn?: Date };
type ConAuditoria = { creadoPor: string; actualizadoPor?: string };

type EntidadCompleta = ConId & ConTimestamps & ConAuditoria & {
  nombre: string;
};

const entidad: EntidadCompleta = {
  id: '123',
  nombre: 'Ejemplo',
  creadoEn: new Date(),
  creadoPor: 'usuario-1',
};

Intersección para componer comportamientos

interface Serializable {
  serializar(): string;
}

interface Comparable<T> {
  compararCon(otro: T): number;
}

interface Clonable<T> {
  clonar(): T;
}

type Producto = {
  id: string;
  nombre: string;
  precio: number;
} & Serializable & Comparable<Producto> & Clonable<Producto>;

Diferencia práctica: `extends` vs `&`

Aunque similares, hay una diferencia importante cuando hay conflictos de propiedades:

interface A { x: string }
interface B extends A { x: string } // ✅ Debe ser compatible

type C = { x: string };
type D = C & { x: number }; // ✅ Compila, pero x es never (string & number)

Con extends, un conflicto es un error de compilación. Con &, el resultado es never, lo que puede pasar desapercibido.

Casos de uso combinados

Resultados de operaciones (inspirado en Rust)

type Resultado<T, E = Error> = 
  | { ok: true; valor: T }
  | { ok: false; error: E };

function dividir(a: number, b: number): Resultado<number, string> {
  if (b === 0) return { ok: false, error: 'División por cero' };
  return { ok: true, valor: a / b };
}

const res = dividir(10, 2);
if (res.ok) {
  console.log(res.valor); // 5 — TypeScript sabe que valor existe aquí
} else {
  console.error(res.error); // TypeScript sabe que error existe aquí
}

Eventos con discriminated union

type Evento =
  | { tipo: 'usuario_registrado'; userId: string; email: string }
  | { tipo: 'pedido_creado'; pedidoId: string; total: number }
  | { tipo: 'pago_procesado'; pedidoId: string; monto: number; método: string }
  | { tipo: 'error'; código: string; mensaje: string };

function manejarEvento(evento: Evento): void {
  switch (evento.tipo) {
    case 'usuario_registrado':
      console.log(`Nuevo usuario: ${evento.email}`);
      break;
    case 'pedido_creado':
      console.log(`Pedido ${evento.pedidoId} por $${evento.total}`);
      break;
    case 'pago_procesado':
      console.log(`Pago de $${evento.monto} por ${evento.método}`);
      break;
    case 'error':
      console.error(`Error ${evento.código}: ${evento.mensaje}`);
      break;
    default:
      verificarExhaustivo(evento);
  }
}

Con estos patrones, el compilador de TypeScript se convierte en un guardián de la lógica de negocio: no solo verifica tipos simples, sino que garantiza que tu código maneja todos los estados posibles del sistema.

En la próxima lección nos enfocamos en el tipado de funciones: firmas, sobrecargas, callbacks y el parámetro especial this.

La propiedad discriminante debe ser un literal
Para que TypeScript pueda estrechar un discriminated union, la propiedad discriminante debe ser un tipo literal (string, number, boolean o symbol literal), no un tipo amplio como 'string'. Así TypeScript sabe exactamente qué tipo es en cada rama del switch.
Narrowing automático con typeof e instanceof
TypeScript aplica narrowing automático con typeof (para primitivos), instanceof (para clases), in (para verificar propiedades), y comparaciones de igualdad. No necesitas casting manual cuando usas estas construcciones.
Las intersecciones de tipos incompatibles producen never
Si haces string & number, el resultado es 'never' porque no existe un valor que sea simultáneamente string y number. TypeScript no lanza un error inmediato, pero nunca podrás crear un valor de ese tipo. Úsalo con cuidado al combinar objetos que tienen la misma propiedad con tipos diferentes.