learning-front

Nivel 5 · TypeScript: JavaScript con red de seguridad

Narrowing: estrechar tipos

typeof, in, instanceof y type guards para que TypeScript afine el tipo según el flujo del código, con unknown como puerta de entrada segura para datos externos.

En los capítulos anteriores tipamos valores sueltos y la forma de los objetos. Pero TypeScript tiene una capacidad más potente todavía: analiza el flujo de tu código y afina el tipo según lo que ya has comprobado. A eso se le llama narrowing (estrechar tipos), y es la herramienta que convierte unknown en algo usable.

Qué es narrowing#

Cuando tienes una variable de tipo string | number, TypeScript no te deja llamar a .toUpperCase() directamente: no sabe si es un string o un número, y .toUpperCase solo existe en string. Pero si compruebas el tipo con un if, TypeScript recuerda ese resultado y dentro del bloque ya sabe cuál de los dos es:

typescript
// procesarValor acepta string o number: una unión de dos posibilidades.
function procesarValor(valor: string | number): string {
  // Aquí valor es string | number: TypeScript no permite .toUpperCase().
  if (typeof valor === "string") {
    // Dentro: TypeScript ha estrechado el tipo a string.
    return "Texto: " + valor.toUpperCase();
  }
  // Aquí TypeScript ha descartado string: valor solo puede ser number.
  return "Numero: " + valor.toFixed(2);
}

Eso es narrowing: TypeScript analiza el flujo (el if, el switch, las comprobaciones) y en cada punto del código sabe el tipo más concreto posible.

typeof: distinguir primitivos#

typeof es el operador más básico para narrowing. Devuelve un string que identifica el tipo primitivo del valor:

typescript
// Los valores posibles de typeof son:
// "string" | "number" | "boolean" | "bigint" | "symbol" | "undefined" | "object" | "function"
if (typeof valor === "string") { ... }
if (typeof valor === "number") { ... }

Ojo con una trampa clásica: typeof null === "object". JavaScript lo tiene así desde su primera versión y no se puede cambiar. Si tu tipo incluye null, hay que descartarlo por separado: typeof x !== "object" || x === null.

in: comprobar si un objeto tiene una propiedad#

El operador in comprueba si un objeto tiene una propiedad con ese nombre. TypeScript lo usa para estrechar entre tipos que comparten algunos campos pero no todos:

typescript
// "propiedad" in objeto → true si la propiedad existe en el objeto.
if ("apodo" in heroe) {
  // Aquí TypeScript sabe que heroe tiene el campo apodo.
  return heroe.nombre + " (" + heroe.apodo + ")";
}

Es especialmente útil cuando tienes dos interfaces y quieres saber cuál de las dos es el objeto que tienes en mano, sin necesidad de un campo discriminante explícito.

instanceof: distinguir instancias de clase#

instanceof comprueba si un objeto fue creado con una clase concreta. Es el narrowing natural para clases, y su uso más frecuente en la práctica es distinguir tipos de error:

typescript
if (error instanceof ErrorDeRed) {
  // error es ErrorDeRed: puedes acceder a .codigo.
  return "Error de red " + error.codigo;
}

Cuando tengas varias clases de error con campos diferentes, instanceof es la forma correcta de saber con cuál estás tratando. Más limpio que comprobar .name o añadir un campo manual.

Truthiness: estrechar con if (valor)#

El truthiness narrowing es la técnica más directa: un if (valor) de JavaScript es false cuando el valor es null, undefined, "", 0, NaN o false. TypeScript aprovecha eso para estrechar el tipo dentro del bloque:

typescript
// apodo?: string → puede ser string o undefined.
if (heroe.apodo) {
  // Aquí apodo es string (TypeScript descartó undefined y "").
  return heroe.nombre + " (" + heroe.apodo + ")";
}

Cuidado con los números: if (n) es false cuando n === 0, que puede ser un valor legítimo. Si el tipo incluye 0, usa n !== null && n !== undefined en vez de if (n).

Type guards: x is T#

Hasta ahora hemos comprobado tipos directamente en el if. Pero cuando la misma comprobación aparece en varios sitios, conviene extraerla a una función. El problema es que una función que devuelve boolean no le dice a TypeScript nada sobre el tipo del argumento después de llamarla.

La solución es un type guard: una función cuyo retorno es param is Tipo. Si devuelve true, TypeScript afina el tipo del argumento en todo el código que la llame:

typescript
// El retorno "dato is Heroe" es el predicado de tipo.
function esHeroe(dato: unknown): dato is Heroe {
  if (typeof dato !== "object" || dato === null) return false;
  // ... comprobaciones de campos ...
  return true;
}

// TypeScript sabe que equipo es Heroe[], no unknown[].
const equipo: Heroe[] = datosApi.filter(esHeroe);

Con boolean puro, filter devolvería unknown[] y necesitarías un cast manual. Con el predicado dato is Heroe, TypeScript afina el tipo automáticamente.

unknown como puerta de entrada segura#

En el capítulo 1 vimos que unknown acepta cualquier valor pero no te deja usarlo hasta que compruebes su tipo. Ahora ya tienes las herramientas para hacerlo:

Verás dos patrones nuevos en el código de esta sección que conviene conocer antes de leer el ejemplo:

  • valor as Tipo — una aserción de tipo. Le dice a TypeScript: “trata este valor como Tipo”. No hace nada en runtime; es solo una instrucción al compilador. Útil cuando tú ya sabes la forma del valor pero TypeScript aún no puede deducirla. Lo verás a fondo en el capítulo de fetch con Zod; por ahora úsalo con cuidado y solo donde sea necesario.

  • Record<string, unknown> — un tipo que significa “objeto con claves de texto y valores aún por comprobar”. object solo le dice a TypeScript “esto no es un primitivo”, pero no qué propiedades tiene. Record<string, unknown> añade ese mínimo de forma para que puedas acceder a las claves con seguridad. Lo verás en detalle en el capítulo de utility types.

typescript
// Un dato de API: llega como unknown hasta que lo compruebas.
const respuesta: unknown = await fetch(...).then(r => r.json());

// TypeScript frena aquí: no puedes acceder a .nombre en un unknown.
// respuesta.nombre  ← error

// Hay que estrechar paso a paso:
if (typeof respuesta === "object" && respuesta !== null) {
  // Ahora es object (no null).
  const obj = respuesta as Record<string, unknown>;
  if (typeof obj.nombre === "string") {
    // Ahora .nombre es string.
    console.log(obj.nombre.toUpperCase());
  }
}

Compara con any: si pones const respuesta: any, TypeScript no protesta aunque accedas a .nombre de un string. El error llegaría en runtime, en producción, cuando ya no puedes hacer nada. unknown te obliga a validar antes de usar, y eso es una red de seguridad.

Exhaustividad con never#

En el capítulo 3 viste las uniones discriminadas: una unión de tipos con un campo común que distingue cada variante. El narrowing más potente sobre ellas es el switch exhaustivo: si TypeScript sabe que has cubierto todos los casos, el default recibe el tipo never (el tipo de algo que no puede ocurrir).

Puedes aprovechar eso para crear una comprobación que rompe en compilación si añades un caso nuevo a la unión y te olvidas de tratarlo en el switch:

typescript
// Esta función solo acepta never: si se puede llamar, hay un caso sin cubrir.
function casoImposible(valor: never): never {
  throw new Error("Caso no cubierto: " + String(valor));
}

function descripcionRol(rol: Rol): string {
  switch (rol) {
    case "Daño":   return "elimina rivales";
    case "Tanque": return "abre camino";
    case "Apoyo":  return "cura al equipo";
    default:
      // Si Rol tiene un cuarto valor sin su case, esto marca error.
      return casoImposible(rol);
  }
}

Añade "Soporte" a type Rol sin añadir su case en el switch y TypeScript te avisa antes de ejecutar. Es una prueba en tiempo de compilación, no en runtime.

Nota sobre never: el parámetro never significa que no puedes llamar a casoImposible con ningún valor real (“no puedes pasarme nada”). El retorno never indica que la función jamás devuelve el control al código que la llamó: siempre lanza una excepción. Son dos usos distintos del mismo tipo.

Comprueba lo que sabes#

Pregunta 1 de 5

¿Qué ocurre cuando TypeScript ve `if (typeof valor === "string") { ... }`?

Tu turno#

Recibes un array de datos con tipo unknown[] que simula una respuesta de API. Algunos son héroes válidos, otros no. Estréchados con seguridad antes de usar sus campos. El panel “Problemas” te guía, y al ejecutar ves el resumen del equipo.

Ejercicio · en esta página

Estréchar datos externos del Team Builder

Recibes un array de datos con tipo `unknown[]` simulando una respuesta de API. Compruébalos con seguridad antes de añadirlos al equipo.

Paso 1: Que funcione

  • Usas typeof e in para comprobar que el dato tiene los campos esperados.
  • El equipo se construye filtrando los datos válidos.
  • La consola muestra el winrate de cada héroe válido.
Ver soluciones
// Solución OK — narrowing con typeof + in
//
// Comprueba que el dato es un objeto con los campos correctos
// usando typeof e in. Funciona y el panel "Problemas" queda vacío.
// Límite: esHeroe devuelve boolean, así que TypeScript no sabe que
// dentro del if el elemento ya ES un Heroe; hay que hacer un cast manual.

// ── Tipos del dominio ───────────────────────────────────────────────
type Rol = "Daño" | "Tanque" | "Apoyo";

interface Heroe {
  readonly nombre: string;
  readonly rol: Rol;
  partidas: number;
  victorias: number;
}

// ── Datos simulados que llegan de una API (tipo unknown) ─────────────
const datosApi: unknown[] = [
  { nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
  { nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 155 },
  { nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 60 },
  { nombre: "Hanzo", rol: "Daño", partidas: 80, victorias: 40 },
  "esto no es un héroe",
];

// ── Comprobación con typeof e in ────────────────────────────────────
// typeof descarta null y primitivos; in comprueba que existan los campos.
function esHeroe(dato: unknown): boolean {
  // typeof null === "object", así que hay que descartarlo explícitamente.
  if (typeof dato !== "object" || dato === null) return false;
  // in comprueba si la propiedad existe en el objeto.
  if (!("nombre" in dato)) return false;
  if (!("rol" in dato)) return false;
  if (!("partidas" in dato)) return false;
  if (!("victorias" in dato)) return false;
  return true;
}

// ── Recoger los héroes válidos ──────────────────────────────────────
// Boolean devuelve true/false pero TypeScript no sabe qué hay dentro,
// así que usamos `as Heroe` para decirle que confiamos en esHeroe.
// (El tipo guard en la solución "mejor" elimina esta necesidad.)
const equipo: Heroe[] = datosApi
  .filter(esHeroe)
  // cast manual necesario: esHeroe devuelve boolean, no `dato is Heroe`.
  // TypeScript no puede afinar el tipo por su cuenta, así que se lo indicamos.
  .map((d) => d as Heroe);

// ── Usar los héroes ─────────────────────────────────────────────────
for (const heroe of equipo) {
  const winrate = ((heroe.victorias / heroe.partidas) * 100).toFixed(1);
  console.log(heroe.nombre + " (" + heroe.rol + "): " + winrate + "%");
}

console.log("Equipo cargado: " + equipo.length + " heroes validos");

Por qué este nivel

  • Usa typeof para descartar primitivos y null, luego in para comprobar que existen los campos: la comprobación mínima que hace funcionar el filtro.
  • Compila sin errores y la consola muestra los héroes válidos.
  • Su límite: esHeroe devuelve boolean, así que TypeScript no afina el tipo automáticamente. Hay que añadir un cast `as Heroe` en el map para que el array sea Heroe[].