Estrechar tipos (narrowing) en TypeScript
En esta lección
Cuando una variable puede ser de varios tipos (string | number, Ruta | undefined), TypeScript no te deja usarla como si fuera solo uno de ellos. Primero tienes que comprobar qué es. Al hacerlo, TypeScript entiende la comprobación y, dentro de ese bloque, trata la variable con un tipo más preciso. A esto se le llama narrowing o estrechar tipos. En esta lección verás todas las comprobaciones que entiende TypeScript, las uniones discriminadas, cómo asegurarte de que tratas todos los casos y cómo escribir tus propias funciones de comprobación.
Por qué hace falta estrechar
Recuerda que una unión como string | number significa “puede ser cualquiera de los dos”. Por eso TypeScript solo te deja usar lo que tienen en común ambos tipos:
function formatearCodigo(codigo: string | number): string {
return codigo.toUpperCase();
}
// error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
Tiene sentido: si te pasan 204, el número no tiene toUpperCase() y el programa fallaría. La solución no es un as (que solo calla al compilador), sino una comprobación real que también sirve al ejecutar:
function formatearCodigo(codigo: string | number): string {
if (typeof codigo === "string") {
return codigo.toUpperCase(); // aquí codigo es string
}
return codigo.toString().padStart(5, "0"); // aquí codigo es number
}
console.log(formatearCodigo("b-12")); // B-12
console.log(formatearCodigo(204)); // 00204
Fíjate en la segunda parte: no hace falta else. Como el if termina con return, TypeScript sabe que si el código sigue, codigo ya no puede ser string, así que solo queda number. Este seguimiento del código, línea a línea, se llama análisis de flujo de control, y es lo que hace posible todo lo que vas a ver.
typeof
El operador typeof de JavaScript devuelve un texto con el tipo de un valor. TypeScript reconoce estas comparaciones como guardas de tipo (type guards), es decir, comprobaciones que estrechan:
typeof x === ... | Estrecha a |
|---|---|
"string" | string |
"number" | number |
"boolean" | boolean |
"bigint" | bigint |
"symbol" | symbol |
"undefined" | undefined |
"function" | funciones |
"object" | objetos, arrays… y null |
Un ejemplo con tres tipos posibles, típico de una configuración que acepta varias formas:
function describirDescanso(descanso: number | string | boolean): string {
if (typeof descanso === "number") {
return `${descanso} segundos de descanso`;
} else if (typeof descanso === "string") {
return `Descanso: ${descanso}`;
} else {
return descanso ? "Con descanso" : "Sin descanso";
}
}
console.log(describirDescanso(90)); // 90 segundos de descanso
console.log(describirDescanso("libre")); // Descanso: libre
console.log(describirDescanso(false)); // Sin descanso
En el último else ya no hace falta comprobar nada: TypeScript ha descartado number y string, así que descanso es boolean.
Cuidado:
typeof nulldevuelve"object". Es un error histórico de JavaScript que no se puede arreglar. Por esotypeof x === "object"no descartanull, y TypeScript te lo recordará con el error'x' is possibly 'null'.
Veracidad: if (valor)
La comprobación más corta es usar el valor directamente como condición. Recuerda que en JavaScript hay valores falsy (que cuentan como falso): false, 0, "", null, undefined y NaN. TypeScript lo sabe y, si la condición se cumple, descarta null y undefined:
function saludar(nombre: string | null): string {
if (nombre) {
return `Hola, ${nombre.trim()}`; // nombre es string
}
return "Hola, visitante"; // nombre es string | null
}
console.log(saludar(" Lucía ")); // Hola, Lucía
console.log(saludar(null)); // Hola, visitante
Es cómodo, pero tiene trampa. Mira esta función que muestra el número de plazas libres de una clase del gimnasio:
function mostrarPlazas(plazas: number | undefined): string {
if (plazas) {
return `Quedan ${plazas} plazas`;
}
return "Sin información";
}
console.log(mostrarPlazas(4)); // Quedan 4 plazas
console.log(mostrarPlazas(0)); // Sin información
Compila sin errores, pero el resultado es incorrecto: con 0 plazas el dato existe (la clase está llena) y la función dice que no hay información. Ni TypeScript puede salvarte aquí, porque el código es válido.
Consejo: si el valor puede ser
0o""y esos valores son válidos, no compruebes la veracidad. Compara explícitamente conundefinedonull, como en la siguiente sección.
Comparaciones con === y !==
Comparar con un valor concreto también estrecha. Es la forma precisa de descartar null o undefined:
function mostrarPlazas(plazas: number | undefined): string {
if (plazas === undefined) {
return "Sin información";
}
return plazas === 0 ? "Clase completa" : `Quedan ${plazas} plazas`;
}
console.log(mostrarPlazas(0)); // Clase completa
console.log(mostrarPlazas(undefined)); // Sin información
Un truco muy usado: x == null (con dos iguales) es verdadero tanto si x es null como si es undefined, así que descarta los dos a la vez. Es de los pocos casos en que == es aceptable.
Cuando comparas dos variables entre sí, TypeScript estrecha las dos al tipo que tienen en común:
function comparar(a: string | number, b: string | boolean): void {
if (a === b) {
console.log(a.toUpperCase(), b.toLowerCase()); // ambos son string
}
}
comparar("ok", "ok"); // OK ok
También funcionan los atajos que ya conoces de JavaScript: el encadenamiento opcional ?. y el operador ??, que da un valor por defecto solo si el de la izquierda es null o undefined.
const socio: { nombre: string; telefono?: string } = { nombre: "Raúl" };
console.log(socio.telefono?.replaceAll(" ", "")); // undefined
console.log(socio.telefono ?? "Sin teléfono"); // Sin teléfono
El operador in
"propiedad" in objeto comprueba si un objeto tiene una propiedad. TypeScript lo usa para distinguir entre tipos de objeto con propiedades distintas:
type Libro = { titulo: string; paginas: number };
type Podcast = { titulo: string; minutos: number };
function duracion(item: Libro | Podcast): string {
if ("paginas" in item) {
return `${item.paginas} páginas`; // item es Libro
}
return `${item.minutos} minutos`; // item es Podcast
}
console.log(duracion({ titulo: "La colmena", paginas: 320 })); // 320 páginas
console.log(duracion({ titulo: "Café con ciencia", minutos: 45 })); // 45 minutos
in es especialmente útil con datos de tipo unknown, como viste en tipos básicos: después de comprobar que algo es un objeto no nulo, "plazas" in datos te deja acceder a datos.plazas.
instanceof
instanceof comprueba si un objeto se creó con una clase. Sirve con las clases del propio JavaScript (Date, Error, Map…) y con las tuyas:
function formatearFecha(fecha: Date | string): string {
if (fecha instanceof Date) {
return fecha.toISOString().slice(0, 10); // fecha es Date
}
return fecha; // fecha es string
}
console.log(formatearFecha(new Date(Date.UTC(2026, 9, 6)))); // 2026-10-06
console.log(formatearFecha("2026-12-24")); // 2026-12-24
Para arrays no uses instanceof, sino Array.isArray, que TypeScript también entiende:
function contarEtiquetas(etiquetas: string | string[]): number {
if (Array.isArray(etiquetas)) {
return etiquetas.length; // string[]
}
return etiquetas.split(",").length; // string
}
console.log(contarEtiquetas(["montaña", "río"])); // 2
console.log(contarEtiquetas("costa,faro,playa")); // 3
Errores en catch
Un uso muy habitual de instanceof: en modo estricto, la variable de un catch tiene tipo unknown, porque en JavaScript se puede lanzar cualquier cosa (un texto, un número…). Para leer el mensaje, primero comprueba que es un Error:
try {
JSON.parse("{ esto no es JSON }");
} catch (e) {
if (e instanceof Error) {
console.log("Error al leer:", e.name);
} else {
console.log("Error desconocido");
}
}
// Error al leer: SyntaxError
Uniones discriminadas
Las comprobaciones con in funcionan, pero cuando tienes varios tipos de objeto relacionados hay un patrón más claro: darles a todos una propiedad común con un valor literal distinto en cada uno. Esa propiedad se llama discriminante (suele llamarse tipo, kind o estado), y la unión resultante, unión discriminada:
type Circulo = { forma: "circulo"; radio: number };
type Rectangulo = { forma: "rectangulo"; ancho: number; alto: number };
type Triangulo = { forma: "triangulo"; base: number; altura: number };
type Figura = Circulo | Rectangulo | Triangulo;
function area(f: Figura): number {
switch (f.forma) {
case "circulo":
return Math.PI * f.radio ** 2;
case "rectangulo":
return f.ancho * f.alto;
case "triangulo":
return (f.base * f.altura) / 2;
}
}
console.log(area({ forma: "rectangulo", ancho: 4, alto: 2.5 })); // 10
console.log(area({ forma: "circulo", radio: 1 }).toFixed(2)); // 3.14
Al comprobar f.forma, TypeScript sabe exactamente qué figura tienes en cada case y te deja usar radio, ancho o base sin quejas. Si en el caso "circulo" escribes f.ancho, te avisa de que esa propiedad no existe en Circulo.
Este patrón aparece constantemente: estados de una petición (cargando, ok, error), acciones de una aplicación, mensajes de un chat… Un ejemplo con el estado de una carga de datos:
type Estado =
| { estado: "cargando" }
| { estado: "ok"; rutas: string[] }
| { estado: "error"; mensaje: string };
function pintar(e: Estado): string {
if (e.estado === "cargando") return "Cargando...";
if (e.estado === "error") return `No se pudo cargar: ${e.mensaje}`;
return `${e.rutas.length} rutas encontradas`;
}
console.log(pintar({ estado: "ok", rutas: ["Cares", "Ruta del Agua"] }));
// 2 rutas encontradas
console.log(pintar({ estado: "error", mensaje: "sin conexión" }));
// No se pudo cargar: sin conexión
Así es imposible tener un estado "ok" sin rutas o un "error" sin mensaje: la forma del objeto va ligada a su estado.
Comprobar que tratas todos los casos con never
¿Qué pasa si mañana añades una figura nueva, Hexagono, y olvidas actualizar area? Para que TypeScript te avise, se usa el tipo never, que representa algo que no puede existir. Si en el default del switch ya has descartado todas las opciones, lo que queda es never, y se puede asignar a una variable de ese tipo:
type Circulo = { forma: "circulo"; radio: number };
type Cuadrado = { forma: "cuadrado"; lado: number };
type Figura = Circulo | Cuadrado;
function area(f: Figura): number {
switch (f.forma) {
case "circulo":
return Math.PI * f.radio ** 2;
case "cuadrado":
return f.lado ** 2;
default: {
const imposible: never = f;
throw new Error(`Figura desconocida: ${JSON.stringify(imposible)}`);
}
}
}
console.log(area({ forma: "cuadrado", lado: 3 })); // 9
Ahora añade una figura a la unión sin tocar el switch:
type Circulo = { forma: "circulo"; radio: number };
type Cuadrado = { forma: "cuadrado"; lado: number };
type Hexagono = { forma: "hexagono"; lado: number };
type Figura = Circulo | Cuadrado | Hexagono;
function area(f: Figura): number {
switch (f.forma) {
case "circulo":
return Math.PI * f.radio ** 2;
case "cuadrado":
return f.lado ** 2;
default: {
const imposible: never = f;
// error TS2322: Type 'Hexagono' is not assignable to type 'never'.
throw new Error(`Figura desconocida: ${JSON.stringify(imposible)}`);
}
}
}
El error te lleva directamente al sitio que tienes que actualizar y además te dice qué caso falta. Esto se llama comprobación de exhaustividad. El throw del final protege también en ejecución, por si llega un dato inesperado de fuera.
Tus propias guardas: predicados de tipo
A veces la comprobación es más complicada que un typeof y quieres reutilizarla en una función. El problema es que, si la función devuelve un simple boolean, TypeScript no sabe qué significa ese true. Para decírselo existe el predicado de tipo (type predicate): en el tipo de retorno escribes parametro is Tipo.
type Socio = { nombre: string; cuota: number };
function esSocio(dato: unknown): dato is Socio {
return (
typeof dato === "object" &&
dato !== null &&
"nombre" in dato &&
typeof dato.nombre === "string" &&
"cuota" in dato &&
typeof dato.cuota === "number"
);
}
const recibido: unknown = JSON.parse('{"nombre":"Marta","cuota":35}');
if (esSocio(recibido)) {
console.log(`${recibido.nombre} paga ${recibido.cuota} €`); // recibido es Socio
}
// Marta paga 35 €
dato is Socio significa: “si esta función devuelve true, dato es un Socio”. Es la forma correcta de validar datos que llegan de fuera (una API, localStorage, un fichero JSON), porque la comprobación existe de verdad al ejecutar.
Cuidado: TypeScript se fía de tu predicado. Si la función devuelve
truesin comprobar bien, habrás mentido al compilador igual que con unas. Revisa que la lógica comprueba todo lo que promete el tipo.
filter con guardas
Los predicados brillan con filter. Desde TypeScript 5.5, el compilador deduce el predicado de funciones flecha sencillas, así que filtrar los undefined de un array ya da un array limpio:
const tiempos = [42.5, undefined, 39.8, undefined, 41];
const validos = tiempos.filter((t) => t !== undefined); // number[]
const mejor = Math.min(...validos);
console.log(validos); // [ 42.5, 39.8, 41 ]
console.log(mejor); // 39.8
En versiones anteriores, validos seguía siendo (number | undefined)[] y había que escribir el predicado a mano: .filter((t): t is number => t !== undefined). Lo verás a menudo en código existente.
Funciones de aserción
Una variante son las funciones de aserción: en lugar de devolver true o false, lanzan un error si la comprobación falla. Se marcan con asserts:
function asegurarTexto(valor: unknown, campo: string): asserts valor is string {
if (typeof valor !== "string") {
throw new Error(`${campo} debe ser un texto`);
}
}
const entrada: unknown = "Ruta del Asón";
asegurarTexto(entrada, "nombre");
console.log(entrada.toUpperCase()); // RUTA DEL ASÓN
Después de la llamada, TypeScript sabe que entrada es string en el resto del bloque, sin necesidad de un if. Son útiles al principio de una función para validar parámetros de una vez.
Cuándo se pierde el estrechamiento
El estrechamiento dura mientras TypeScript puede asegurar que el valor no ha cambiado. Si vuelves a asignar la variable, el tipo se recalcula:
let precio: number | string = "12,50";
precio = Number(precio.replace(",", ".")); // ahora precio es number
console.log(precio.toFixed(2)); // 12.50
Y dentro de una función flecha creada después de la comprobación, una variable let vuelve a su tipo original, porque la función podría ejecutarse más tarde, cuando ya haya cambiado. Si tienes problemas así, copia el valor a una const: las constantes no pueden cambiar y conservan el tipo estrechado.
Errores frecuentes
- Usar
asen lugar de comprobar:(codigo as string).toUpperCase()compila, pero falla al ejecutar si llega un número. Estrecha contypeof,inoinstanceof. - Olvidar que
typeof nulles"object": comprueba tambiénx !== nullantes de tratar algo como objeto. - Comprobar veracidad con números o textos:
if (cantidad)descarta el0yif (nombre)el texto vacío. Compara conundefinedonullcuando esos valores sean válidos. - Usar
instanceofcon interfaces o tipos: las interfaces no existen al ejecutar, así quex instanceof Sociono compila siSocioes untypeo unainterface. Usain, un discriminante o un predicado de tipo. - Predicados de tipo que mienten: si
esSociosolo comprueba el nombre, TypeScript creerá que también hay cuota. Comprueba todo lo que promete el tipo. - No cerrar los
switchde uniones discriminadas: añade eldefaultconneverpara que el compilador te avise cuando aparezca un caso nuevo.
Resumen
| Técnica | Ejemplo | Estrecha a |
|---|---|---|
typeof | typeof x === "string" | Tipos primitivos |
| Veracidad | if (nombre) | Quita null y undefined (¡y 0, ""!) |
| Igualdad | x !== undefined, x == null | Quita o deja valores concretos |
in | "paginas" in item | Tipos de objeto con esa propiedad |
instanceof | fecha instanceof Date | Instancias de una clase |
Array.isArray | Array.isArray(x) | Arrays |
| Unión discriminada | switch (f.forma) | Cada miembro de la unión |
| Exhaustividad | const imposible: never = f | Error si falta un caso |
| Predicado de tipo | function esSocio(d: unknown): d is Socio | Lo que digas tú |
| Función de aserción | asserts valor is string | El resto del bloque |
En la siguiente lección aplicarás los tipos a la programación orientada a objetos con las clases.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.