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)
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
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.
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
| Para | Escribe | Estrecha a |
|---|---|---|
| Varios tipos posibles | A | B | solo deja usar lo común |
| Distinguir primitivos | typeof x === "string" | string |
| Descartar null o undefined | if (x), x?.y, x ?? def | el tipo sin null |
| Mirar si hay una propiedad | "ladrar" in animal | el miembro que la tiene |
| Mirar la clase | x instanceof Date | Date |
| Objetos con una etiqueta común | switch (r.estado) | el objeto de ese caso |
| Que avise del caso olvidado | const _: never = r | error si falta uno |
| Tu propia comprobación | (x: unknown): x is T | T |
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.
