Saltar al contenido
narrowing.ts · devschool

Estrechar tipos (narrowing) en TypeScript

Lección 8 de 15 · 15 min de lectura · Actualizado el

En esta lección
  1. Por qué hace falta estrechar
  2. typeof
  3. Veracidad: if (valor)
  4. Comparaciones con === y !==
  5. El operador in
  6. instanceof
  7. Uniones discriminadas
  8. Comprobar que tratas todos los casos con never
  9. Tus propias guardas: predicados de tipo
  10. Cuándo se pierde el estrechamiento
  11. Errores frecuentes
  12. Resumen

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 null devuelve "object". Es un error histórico de JavaScript que no se puede arreglar. Por eso typeof x === "object" no descarta null, 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 0 o "" y esos valores son válidos, no compruebes la veracidad. Compara explícitamente con undefined o null, 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 true sin comprobar bien, habrás mentido al compilador igual que con un as. 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 as en lugar de comprobar: (codigo as string).toUpperCase() compila, pero falla al ejecutar si llega un número. Estrecha con typeof, in o instanceof.
  • Olvidar que typeof null es "object": comprueba también x !== null antes de tratar algo como objeto.
  • Comprobar veracidad con números o textos: if (cantidad) descarta el 0 y if (nombre) el texto vacío. Compara con undefined o null cuando esos valores sean válidos.
  • Usar instanceof con interfaces o tipos: las interfaces no existen al ejecutar, así que x instanceof Socio no compila si Socio es un type o una interface. Usa in, un discriminante o un predicado de tipo.
  • Predicados de tipo que mienten: si esSocio solo comprueba el nombre, TypeScript creerá que también hay cuota. Comprueba todo lo que promete el tipo.
  • No cerrar los switch de uniones discriminadas: añade el default con never para que el compilador te avise cuando aparezca un caso nuevo.

Resumen

TécnicaEjemploEstrecha a
typeoftypeof x === "string"Tipos primitivos
Veracidadif (nombre)Quita null y undefined (¡y 0, ""!)
Igualdadx !== undefined, x == nullQuita o deja valores concretos
in"paginas" in itemTipos de objeto con esa propiedad
instanceoffecha instanceof DateInstancias de una clase
Array.isArrayArray.isArray(x)Arrays
Unión discriminadaswitch (f.forma)Cada miembro de la unión
Exhaustividadconst imposible: never = fError si falta un caso
Predicado de tipofunction esSocio(d: unknown): d is SocioLo que digas tú
Función de aserciónasserts valor is stringEl 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

[TypeScript] ¿Qué devuelve mostrarPlazas(0)?
function mostrarPlazas(plazas: number | undefined): string {
  if (plazas) {
    return `Quedan ${plazas} plazas`;
  }
  return "Sin información";
}

[TypeScript] ¿Por qué typeof x === "object" no basta para trabajar con seguridad con un objeto?

[TypeScript] Añades Hexagono a la unión Figura pero olvidas actualizar el switch. ¿Por qué se queja TypeScript en el default?
default: {
  const imposible: never = f;
  throw new Error("Figura desconocida");
}

[TypeScript] En modo estricto, ¿cómo lees e.message dentro de un catch (e)?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.