Unión, Intersección y Narrowing en TypeScript

1. Descripción general

Union e intersection son dos herramientas básicas para que TypeScript describa datos de forma más flexible que una simple interface. Narrowing es una técnica que ayuda al compilador a “entender” el tipo específico de un valor en cada rama del código, en lugar de obligarte a hacer casting (as) manual.

Este artículo parte de los conceptos de union/intersection, pasando por las técnicas de narrowing comunes (typeof, instanceof, in, discriminated union), hasta cómo escribir type guards personalizados. Una vez terminado, sabrás cuándo usar union, cuándo usar intersection, y evitarás errores como Object is possibly undefined o as any desenfrenado en tu código.

2. Union type

Union (|) significa que un valor puede pertenecer a uno de varios tipos.

type PaymentMethod = "cash" | "card" | "e-wallet";

function describe(method: PaymentMethod): string {
  return `Pagado con: ${method}`;
}

describe("card"); // OK
// describe("paypal"); // Error en compilación: no está en la union

PaymentMethod solo acepta 3 cadenas literales específicas. Si pasas un valor diferente, TypeScript reporta un error en tiempo de compilación, no tienes que esperar hasta runtime.

Union también se puede aplicar con tipos de objeto:

interface CashPayment {
  type: "cash";
  amount: number;
}

interface CardPayment {
  type: "card";
  amount: number;
  cardNumber: string;
}

type Payment = CashPayment | CardPayment;

En este caso, una variable de tipo Payment puede ser CashPayment o CardPayment, pero TypeScript solo te permite acceder a los campos comunes de ambos (que aquí son type y amount) si no has hecho narrowing antes.

function printAmount(p: Payment) {
  console.log(p.amount); // OK, campo común
  // console.log(p.cardNumber); // Error: no existe en CashPayment
}

Nota: Union no combina los campos. Para acceder a campos específicos, primero debes hacer narrowing (ver sección 4).

3. Intersection type

Intersection (&) significa que un valor debe satisfacer todos a la vez los tipos que se combinan.

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
}

type Order = Identifiable & Timestamped & {
  customerName: string;
  total: number;
};

const order: Order = {
  id: "ORD-001",
  createdAt: new Date(),
  customerName: "Nguyễn Văn A",
  total: 150_000,
};

Order aquí tiene todos los campos de Identifiable, Timestamped y del objeto literal agregado. Si falta cualquier campo, el compilador reportará un error.

Intersection es útil cuando quieres “mezclar” varios mixin pequeños en un tipo más grande, similar a composition en OOP pero a nivel de tipo.

type AuditInfo = { updatedBy: string };

type AuditedOrder = Order & AuditInfo;

const audited: AuditedOrder = {
  ...order,
  updatedBy: "admin",
};

3.1. Intersection con tipos primitivos en conflicto

Si dos tipos en una intersection entran en conflicto respecto al tipo primitivo, el resultado es never:

type Impossible = string & number; // tipo never, ningún valor puede satisfacer esto

Este caso generalmente es una señal de que escribiste & en lugar de |.

4. Narrowing: reducir el tipo en cada rama del código

Narrowing es el proceso en el cual el compilador deduce automáticamente un tipo más específico basándose en las condiciones de verificación en el código (if, switch, typeof…).

4.1. Narrowing con typeof

Se usa para uniones que contienen tipos primitivos.

function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // id se reduce al tipo number
  }
  return id.toUpperCase(); // id se reduce al tipo string
}

Dentro de la rama if, TypeScript sabe que id es number por lo que permite llamar .toFixed(). En la rama else (implícita), id solo puede ser string.

4.2. Narrowing con instanceof

Se usa para uniones que contienen clases.

class ValidationError extends Error {
  constructor(public field: string) {
    super(`Field ${field} is invalid`);
  }
}

function handleError(err: Error | ValidationError): void {
  if (err instanceof ValidationError) {
    console.log(`Error de validación en campo: ${err.field}`);
  } else {
    console.log(`Otro error: ${err.message}`);
  }
}

err.field solo existe en ValidationError, por lo que el compilador solo permite acceder a él en la rama que ya ha verificado con instanceof.

4.3. Narrowing con in

Se usa cuando los tipos en la union se distinguen por campos específicos (sin un campo discriminante común).

interface Dog {
  bark: () => void;
}

interface Cat {
  meow: () => void;
}

function makeSound(pet: Dog | Cat): void {
  if ("bark" in pet) {
    pet.bark(); // se reduce al tipo Dog
  } else {
    pet.meow(); // se reduce al tipo Cat
  }
}

El operador in verifica si una propiedad existe en el objeto en tiempo de ejecución, y el compilador usa ese resultado para reducir el tipo correspondiente.

4.4. Discriminated union — la forma más segura

Este es el patrón más común en código real: añadir un campo literal común (generalmente llamado kind o type) para distinguir las “ramas” en la unión.

interface SuccessResult {
  status: "success";
  data: string;
}

interface ErrorResult {
  status: "error";
  message: string;
}

type ApiResult = SuccessResult | ErrorResult;

function handleResult(result: ApiResult): void {
  switch (result.status) {
    case "success":
      console.log(`Datos: ${result.data}`);
      break;
    case "error":
      console.log(`Error: ${result.message}`);
      break;
  }
}

El compilador usa el campo status como “discriminante”: en el case "success", result se reduce al tipo SuccessResult; en el case "error", se reduce al tipo ErrorResult. Esta es la forma recomendada por TypeScript para cualquier union más compleja que 2 ramas, porque switch también soporta verificación de exhaustividad (sección 4.6).

4.5. Narrowing con verificación de veracidad y encadenamiento opcional

interface Customer {
  name: string;
  phone?: string;
}

function callCustomer(customer: Customer): void {
  if (customer.phone) {
    // customer.phone se reduce de string | undefined a string
    console.log(`Llamar al: ${customer.phone}`);
  } else {
    console.log("El cliente no tiene número de teléfono");
  }
}

La verificación de veracidad (if (customer.phone)) elimina undefined (y las cadenas vacías, pero para números de teléfono esto no es importante).

4.6. Verificación de exhaustividad con never

Cuando añades una rama nueva a una union pero olvidas manejarla, quieres que el compilador reporte un error inmediatamente, en lugar de un bug silencioso en tiempo de ejecución. Usa una variable de tipo never en la rama default:

function assertUnreachable(x: never): never {
  throw new Error(`Caso no manejado: ${JSON.stringify(x)}`);
}

function handleResultSafe(result: ApiResult): string {
  switch (result.status) {
    case "success":
      return result.data;
    case "error":
      return result.message;
    default:
      return assertUnreachable(result); // si añades una rama pero olvidas manejarla, error de compilación aquí
  }
}

Si después alguien añade PendingResult a ApiResult pero olvida añadir el case correspondiente, result en la rama default ya no será never, y el compilador reportará un error cuando se compile — sin tener que esperar a que aparezca el bug en producción.

5. Type guard personalizado

Cuando la lógica de verificación es más compleja que una expresión simple, puedes escribir una función que retorna el tipo x is T para reutilizar el narrowing.

interface PaidOrder {
  status: "paid";
  paidAt: Date;
}

interface PendingOrder {
  status: "pending";
}

type OrderState = PaidOrder | PendingOrder;

function isPaid(order: OrderState): order is PaidOrder {
  return order.status === "paid";
}

function printReceipt(order: OrderState): void {
  if (isPaid(order)) {
    console.log(`Pagado en: ${order.paidAt.toISOString()}`);
  } else {
    console.log("Aún no pagado");
  }
}

order is PaidOrder es un type predicate. El compilador confía en el resultado de la función isPaid para reducir order al tipo PaidOrder en la rama if, incluso cuando la lógica de verificación está en una función separada.

6. Pruebas para verificar

Narrowing es una característica de tiempo de compilación, así que “pruebas” realmente significa ejecutar tsc sin reportar errores. Pero aún debes escribir pruebas para el comportamiento de tiempo de ejecución correspondiente.

npm install -D vitest typescript
import { describe, it, expect } from "vitest";

function totalAmount(payment: CashPayment | CardPayment): number {
  return payment.amount;
}

describe("Payment narrowing", () => {
  it("calcula correctamente la cantidad para pago en efectivo", () => {
    const cash: CashPayment = { type: "cash", amount: 50_000 };
    expect(totalAmount(cash)).toBe(50_000);
  });

  it("calcula correctamente la cantidad para pago con tarjeta", () => {
    const card: CardPayment = {
      type: "card",
      amount: 200_000,
      cardNumber: "4111111111111111",
    };
    expect(totalAmount(card)).toBe(200_000);
  });
});

Ejecuta npx vitest run para obtener el resultado:

✓ Payment narrowing > calcula correctamente la cantidad para pago en efectivo
✓ Payment narrowing > calcula correctamente la cantidad para pago con tarjeta

Test Files  1 passed (1)
     Tests  2 passed (2)

7. Errores comunes y cuándo no usar

  • Abuso de as para evitar narrowing: si te encuentras con frecuencia haciendo casting (as SomeType) para “complacer” al compilador, probablemente tu union/type guard esté mal diseñado. Vuelve a usar discriminated union.
  • Intersection de dos interfaces con campos del mismo nombre pero diferente tipo: TypeScript intentará fusionarlas, pero si los tipos entran en conflicto (por ejemplo, string en un lado y number en el otro), ese campo se convierte en never — muy fácil de causar errores confusos. Siempre usa nombres de campos consistentes al diseñar mixins.
  • Uniones demasiado amplias (por ejemplo string | number | boolean | object): hacen que en cualquier lugar donde uses el tipo tengas que hacer narrowing, y el código se vuelve desordenado. Si una union tiene más de 4–5 ramas no relacionadas, revisa tu modelo de dominio — probablemente deberías dividirla en múltiples funciones/overloads separadas.
  • Olvidar default con never en el switch: pierdes el beneficio de la verificación de exhaustividad, es fácil olvidar una rama cuando amplías la union.
  • Operador in en objetos con cadena de prototipos compleja: in verifica también propiedades heredadas, puede dar resultados de narrowing inesperados con clases que tienen herencia profunda. En ese caso, instanceof o un campo discriminante son más confiables.

Nota: Narrowing solo funciona bien cuando TypeScript puede demostrar mediante control flow estático. Si asignas un valor reducido a otra variable a través de una función callback asincrónica (setTimeout, promise .then), el tipo reducido puede “olvidarse” — TypeScript no rastrea a través de los límites de closure de forma absoluta en todos los casos.

8. Conclusión

Union describe “uno de varios tipos”, intersection describe “todos los tipos simultáneamente” — estas dos herramientas son la base para construir un modelo de dominio preciso en TypeScript. Narrowing (typeof, instanceof, in, discriminated union, type guards personalizados) te ayuda a aprovechar las unions de forma segura sin necesidad de hacer casting manual.

Reglas prácticas: prefiere discriminated union cuando hay 2 o más ramas, siempre añade verificación de exhaustividad con never en switch, y evita intersection entre tipos que tengan campos del mismo nombre pero con tipos diferentes. Aplicando correctamente, atraparás muchos errores lógicos justo cuando escribas el código, antes de que tengas oportunidad de llegar a producción.

typescripttype-safetyjavascriptfrontend