Uniones y estrechamiento en TypeScript

Lección 3 de 8. Un valor que puede ser un número o un texto, un resultado que puede no existir, una respuesta que puede ser un éxito o un error: las uniones describen todo eso, y el estrechamiento es cómo TypeScript sigue tus if para saber qué tienes en cada rama. Es la lección que más errores de producción evita.

Uniones: esto o aquello

Muchos valores reales pueden ser de más de un tipo: un identificador que a veces es un número y a veces un texto, un resultado que puede no existir. Una unión lo dice con una barra vertical, number | string, que se lee «número o texto». Ya la viste de pasada en la lección 2: una propiedad opcional es en realidad una unión con undefined.

let id: number | string;
id = 42;        // bien
id = "A-42";    // bien
id = true;      // error: boolean no está en la unión

function mostrar(id: number | string) {
  console.log(id.toUpperCase());   // error: un number no tiene toUpperCase
}

Ábrelo en el editor y cámbialo →

tsc --strictunion.ts(4,1): error TS2322: El tipo 'boolean' no se puede asignar al tipo 'string | number'.
union.ts(7,18): error TS2339: La propiedad 'toUpperCase' no existe en el tipo 'string | number'.
  La propiedad 'toUpperCase' no existe en el tipo 'number'.

El segundo error es la clave de toda la lección. Con una unión, TypeScript solo te deja usar lo que tienen en común todos sus miembros: toString() sí, porque lo tienen los números y los textos, pero toUpperCase() no, porque id podría ser un número. Para usar lo específico de cada tipo hay que comprobar antes cuál es.

Estrechar con typeof

En cuanto escribes un if que comprueba el tipo, TypeScript lo tiene en cuenta: dentro de la rama, el tipo del valor se estrecha a lo que ha pasado la comprobación. Es el mismo typeof de JavaScript que conoces de la lección 2 del curso de JavaScript; TypeScript solo lo lee.

function formatear(valor: string | number | null): string {
  if (valor === null) {
    return "(vacío)";                     // aquí valor es null
  }
  if (typeof valor === "string") {
    return valor.toUpperCase();           // aquí, string
  }
  return valor.toFixed(2);                // aquí ya solo queda number
}

console.log(formatear("hola"));
console.log(formatear(3.14159));
console.log(formatear(null));

Ábrelo en el editor y cámbialo →

salidaHOLA
3.14
(vacío)

Un valor de tipo string, number o null pasa por un if que comprueba null y otro con typeof; en cada rama el tipo se reduce hasta quedar en null, string o numberCada comprobación quita opciones: el tipo se estrecha rama a ramastring | number | nullel tipo de valor al entrarif (valor === null)nullrama del ifsístring | numberrama del else: ya no es nullnotypeof valor === "string"stringsínumbernoEn cada rama, TypeScript sabequé opciones quedan y solo dejausar lo que tienen en común.

El estrechamiento sigue el flujo del programa: después de un return, lo que queda debajo ya no puede ser lo que salió. Esquema: decodigo.com.

Fíjate en la última línea de la función: no hay ningún if, pero TypeScript sabe que valor es un número, porque los casos null y string ya salieron con return. El análisis sigue el flujo del programa, no solo las llaves.

null y undefined en modo estricto

Con el modo estricto, que en TypeScript 7 viene activado por omisión, null y undefined no caben en ningún otro tipo: un string nunca es null. Si algo puede faltar, su tipo lo dice, y el compilador no te deja usarlo sin comprobarlo. El caso más frecuente es find, que devuelve undefined cuando no encuentra nada:

El error que te ahorra: usar algo que puede no existir

const usuarios = [{ id: 1, nombre: "Ana" }, { id: 2, nombre: "Luis" }];
const u = usuarios.find((x) => x.id === 3);
console.log(u.nombre.toUpperCase());
node find_antes.mjsTypeError: Cannot read properties of undefined (reading 'nombre')

El programa funciona en todas las pruebas con usuarios que existen, y falla el día que alguien pide uno que no está. En TypeScript, el tipo de u es { id: number; nombre: string; } | undefined, y el compilador lo dice antes:

const usuarios = [{ id: 1, nombre: "Ana" }, { id: 2, nombre: "Luis" }];
const u = usuarios.find((x) => x.id === 3);
console.log(u.nombre.toUpperCase());
tsc --strictfind.ts(3,13): error TS18048: "u" es posiblemente "undefined".

Las tres formas de resolverlo son las de JavaScript, y las tres estrechan el tipo: comprobarlo con un if, el encadenamiento opcional ?. o un valor por omisión con ?? (los dos operadores están en la lección 3 del curso de JavaScript).

const usuarios = [{ id: 1, nombre: "Ana" }, { id: 2, nombre: "Luis" }];
const u = usuarios.find((x) => x.id === 3);

if (u) {
  console.log(u.nombre.toUpperCase());   // dentro del if, u existe
}
console.log(u?.nombre ?? "no encontrado");

Ábrelo en el editor y cámbialo →

salidano encontrado
El signo de exclamación. También existe u!.nombre, que le dice al compilador «confía en mí, no es undefined». No comprueba nada: si te equivocas, el programa falla igual que en JavaScript. Úsalo solo cuando sepas algo que el compilador no puede saber, y nunca para callar un error que no entiendes. Lo mismo pasa con as, como verás en las rarezas de TypeScript.

Estrechar objetos: in e instanceof

typeof solo distingue tipos primitivos: para cualquier objeto responde "object". Para objetos hay otras dos comprobaciones que TypeScript también entiende: in, que mira si un objeto tiene una propiedad, e instanceof, que mira de qué clase es:

type Perro = { nombre: string; ladrar(): string };
type Pez = { nombre: string; nadar(): string };

function mover(animal: Perro | Pez): string {
  if ("ladrar" in animal) {
    return animal.ladrar();     // aquí, Perro
  }
  return animal.nadar();        // aquí, Pez
}

function fecha(valor: Date | string): string {
  if (valor instanceof Date) {
    return valor.toISOString().slice(0, 10);
  }
  return valor;
}

console.log(mover({ nombre: "Toby", ladrar: () => "¡Guau!" }));
console.log(mover({ nombre: "Nemo", nadar: () => "glu, glu" }));
console.log(fecha(new Date(Date.UTC(2026, 9, 20))), fecha("ayer"));

Ábrelo en el editor y cámbialo →

salida¡Guau!
glu, glu
2026-10-20 ayer

Uniones de literales: una lista cerrada de valores

Los tipos literales de la lección 2 se vuelven útiles al unirlos: "S" | "M" | "L" es un tipo que solo admite esos tres textos. Sustituye a las constantes mágicas y, de paso, el editor te los autocompleta:

type Talla = "S" | "M" | "L";

function precio(talla: Talla): number {
  return talla === "L" ? 12 : 10;
}

console.log(precio("M"));
console.log(precio("XL"));   // error

Ábrelo en el editor y cámbialo →

tsc --strictliterales.ts(8,20): error TS2345: No se puede asignar un argumento de tipo ""XL"" al parámetro de tipo "Talla".

Uniones discriminadas

Es el patrón más útil de toda la lección. Cuando una unión junta varios objetos, se les pone a todos una propiedad común con un valor literal distinto, normalmente tipo o kind. Al comprobar esa propiedad, TypeScript sabe exactamente cuál de los objetos tienes y qué otras propiedades existen:

type Respuesta =
  | { estado: "ok"; datos: string[] }
  | { estado: "error"; mensaje: string };

function describir(r: Respuesta): string {
  switch (r.estado) {
    case "ok":
      return `${r.datos.length} resultados`;   // aquí existe datos
    case "error":
      return `Falló: ${r.mensaje}`;            // aquí existe mensaje
  }
}

console.log(describir({ estado: "ok", datos: ["a", "b", "c"] }));
console.log(describir({ estado: "error", mensaje: "sin conexión" }));

Ábrelo en el editor y cámbialo →

salida3 resultados
Falló: sin conexión

Con esto se representan muy bien los estados de una pantalla (cargando, con datos, con error) sin las combinaciones imposibles que salen de tener tres variables sueltas, como «cargando y con error a la vez». Hay un ejemplo completo en Uniones discriminadas para los estados de una pantalla.

Que el compilador te avise del caso olvidado: never

¿Qué pasa si mañana alguien añade un tercer estado a Respuesta? Con el switch de arriba, el compilador ya protesta, aunque de forma indirecta: la función podría acabar sin devolver nada.

type Respuesta =
  | { estado: "ok"; datos: string[] }
  | { estado: "error"; mensaje: string }
  | { estado: "cargando" };                   // el caso nuevo

function describir(r: Respuesta): string {
  switch (r.estado) {
    case "ok":
      return `${r.datos.length} resultados`;
    case "error":
      return `Falló: ${r.mensaje}`;
  }
}

Ábrelo en el editor y cámbialo →

tsc --strictsin_default.ts(6,35): error TS2366: Falta la instrucción "return" final en la función y el tipo de valor devuelto no incluye 'undefined'.

Ese aviso no dice qué caso falta, y si la función no devolviera nada (si solo escribiera en pantalla, por ejemplo), no habría ningún aviso. Hay una forma más explícita, que funciona también cuando no se devuelve nada: añadir un default que asigna el valor a una variable de tipo never, el conjunto vacío de la lección 2. Si todos los casos están cubiertos, ahí no puede llegar nada y compila. Si falta uno, ese caso llega al default y el compilador te dice cuál:

type Respuesta =
  | { estado: "ok"; datos: string[] }
  | { estado: "error"; mensaje: string }
  | { estado: "cargando" };                   // el caso nuevo

function describir(r: Respuesta): string {
  switch (r.estado) {
    case "ok":
      return `${r.datos.length} resultados`;
    case "error":
      return `Falló: ${r.mensaje}`;
    default:
      const _sinCubrir: never = r;           // error si queda algún caso
      return _sinCubrir;
  }
}

Ábrelo en el editor y cámbialo →

tsc --strictexhaustivo.ts(13,13): error TS2322: El tipo '{ estado: "cargando"; }' no se puede asignar al tipo 'never'.

El mensaje nombra el caso que falta. En un proyecto grande, con esa misma unión usada en veinte sitios, el compilador te lleva uno por uno a todos los switch que hay que actualizar.

Tus propias comprobaciones: guardas de tipo

Si una comprobación se repite, conviene sacarla a una función. Pero una función normal que devuelve boolean no estrecha nada: TypeScript no mira dentro. Para que lo haga, el tipo devuelto se escribe como valor is Tipo, una guarda de tipo:

type Producto = { nombre: string; precio: number };

function esProducto(x: unknown): x is Producto {
  return typeof x === "object" && x !== null
    && typeof (x as Producto).nombre === "string"
    && typeof (x as Producto).precio === "number";
}

const datos: unknown = JSON.parse('{"nombre": "Taza", "precio": 8.5}');
if (esProducto(datos)) {
  console.log(datos.nombre, datos.precio * 2);   // aquí datos es Producto
}

const mezcla = [3, null, 7, null, 1];
const numeros: number[] = mezcla.filter((n) => n !== null);
console.log(numeros);

Ábrelo en el editor y cámbialo →

salidaTaza 17
[ 3, 7, 1 ]

La segunda parte enseña algo que hasta hace poco no funcionaba: desde TypeScript 5.5, el compilador deduce solo la guarda de una función flecha sencilla como (n) => n !== null, así que filter devuelve un number[] sin null. Antes había que escribir (n): n is number => ... a mano.

Una guarda es una promesa, no una comprobación. TypeScript se fía de lo que diga la función: si esProducto devolviera true sin mirar nada, el compilador la creería. Por eso validar datos de fuera a mano es tedioso y propenso a errores; en la lección 8 lo hacemos con Zod, que escribe estas comprobaciones por ti.

Resumen

ParaEscribeEstrecha a
Varios tipos posiblesA | Bsolo deja usar lo común
Distinguir primitivostypeof x === "string"string
Descartar null o undefinedif (x), x?.y, x ?? defel tipo sin null
Mirar si hay una propiedad"ladrar" in animalel miembro que la tiene
Mirar la clasex instanceof DateDate
Objetos con una etiqueta comúnswitch (r.estado)el objeto de ese caso
Que avise del caso olvidadoconst _: never = rerror si falta uno
Tu propia comprobación(x: unknown): x is TT

Retos

Reto 1. Escribe longitud(x: string | string[]): number, que devuelva el número de caracteres si es un texto y el número total de caracteres de todos los elementos si es un array.

Ver la solución
function longitud(x: string | string[]): number {
  if (typeof x === "string") {
    return x.length;
  }
  return x.reduce((total, s) => total + s.length, 0);
}

console.log(longitud("hola"), longitud(["ab", "cde"]));

Ábrelo en el editor y cámbialo →

salida4 5

Para el array también valdría Array.isArray(x), que TypeScript entiende igual.

Reto 2. Define type Figura como unión discriminada de un círculo (con radio) y un rectángulo (con ancho y alto), y escribe area(f: Figura) con la comprobación de exhaustividad. Después añade un triángulo a la unión sin tocar la función: ¿qué dice el compilador?

Ver la solución
type Figura =
  | { tipo: "circulo"; radio: number }
  | { tipo: "rectangulo"; ancho: number; alto: number }
  | { tipo: "triangulo"; base: number; altura: number };

function area(f: Figura): number {
  switch (f.tipo) {
    case "circulo":
      return Math.PI * f.radio ** 2;
    case "rectangulo":
      return f.ancho * f.alto;
    default:
      const _sinCubrir: never = f;
      return _sinCubrir;
  }
}

Ábrelo en el editor y cámbialo →

tsc --strictreto2.ts(13,13): error TS2322: El tipo '{ tipo: "triangulo"; base: number; altura: number; }' no se puede asignar al tipo 'never'.

El compilador señala el default y nombra el triángulo. Al añadir su case con f.base * f.altura / 2, vuelve a compilar.

Reto 3. ¿Por qué este código no compila, si la condición parece suficiente? function f(x: string | number) { if (typeof x !== "number" && x.length > 3) return x.toFixed(1); }

Ver la solución
function f(x: string | number) {
  if (typeof x !== "number" && x.length > 3) return x.toFixed(1);
}

Ábrelo en el editor y cámbialo →

tsc --strictreto3.ts(2,55): error TS2551: La propiedad "toFixed" no existe en el tipo "string". ¿Quería decir "fixed"?

Dentro de la condición, después de typeof x !== "number", x ya es un string: por eso x.length funciona y x.toFixed no. El error lo encuentra el compilador; en JavaScript habría saltado al ejecutar, y solo con textos de más de tres letras.

Todo el código de esta lección se comprobó con tsc 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. ¿Quieres probar algo? Usa el editor de TypeScript.