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:
// 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:
// 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:
// "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:
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:
// 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:
// 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 comoTipo”. 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”.objectsolo 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.
// 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:
// 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.
Paso 2: Que esté pulido
- esHeroe devuelve `dato is Heroe` (type guard), no boolean.
- filter(esHeroe) ya da Heroe[] sin ningún cast manual.
- Compruebas también el valor del campo rol, no solo su existencia.
Paso 3: Que sea excelente
- Usas exhaustividad con never: si añades un rol nuevo a la unión, TypeScript te obliga a tratarlo.
- El resultado de la validación es una unión discriminada (tipo: "ok" | "error") que separa héroes válidos de motivos de rechazo.
- Aserciones `as` mínimas, todas encapsuladas en la función de validación; el código que consume los datos ya validados no necesita ninguna. Cero `any`.
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[].
// Solución mejor — type guard reutilizable (x is Heroe)
//
// esHeroe devuelve `dato is Heroe` (no boolean): TypeScript entiende
// que dentro de cualquier if(esHeroe(x)) el tipo ya es Heroe.
// Sin `as`, sin casts manuales. Maneja todos los casos posibles.
// ── 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",
{ nombre: "Widowmaker", rol: "Categoría Desconocida", partidas: 50, victorias: 30 },
];
// ── Helper: comprobar que el rol es válido ──────────────────────────
// Usamos includes sobre un array de literales para acotar el tipo.
const ROLES_VALIDOS: Rol[] = ["Daño", "Tanque", "Apoyo"];
function esRolValido(valor: unknown): valor is Rol {
// typeof string descarta números, booleans, objetos.
if (typeof valor !== "string") return false;
// includes comprueba que el string está en la lista de roles conocidos.
return (ROLES_VALIDOS as string[]).includes(valor);
}
// ── Type guard: dato is Heroe ────────────────────────────────────────
// El retorno "dato is Heroe" es un predicado de tipo:
// cuando esta función devuelve true, TypeScript sabe que dato es Heroe.
function esHeroe(dato: unknown): dato is Heroe {
// typeof null === "object", así que descartamos null primero.
if (typeof dato !== "object" || dato === null) return false;
// Tras el typeof, TypeScript sabe que dato es object.
// Comprobamos cada campo con in + su tipo.
if (!("nombre" in dato) || typeof (dato as Record<string, unknown>).nombre !== "string") return false;
if (!("rol" in dato) || !esRolValido((dato as Record<string, unknown>).rol)) return false;
if (!("partidas" in dato) || typeof (dato as Record<string, unknown>).partidas !== "number") return false;
if (!("victorias" in dato) || typeof (dato as Record<string, unknown>).victorias !== "number") return false;
return true;
}
// ── Recoger los héroes válidos ──────────────────────────────────────
// filter con un type guard afina el tipo del array resultante:
// TypeScript sabe que equipo es Heroe[], sin ningún cast.
const equipo: Heroe[] = datosApi.filter(esHeroe);
// ── 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 + "%");
}
// Los datos que no pasaron la guarda:
const invalidos = datosApi.filter((d) => !esHeroe(d));
console.log("Equipo cargado: " + equipo.length + " heroes validos");
console.log("Datos ignorados: " + invalidos.length); Por qué es mejor que el anterior
- esHeroe devuelve `dato is Heroe`: un predicado de tipo. Cuando filter usa esta función, TypeScript sabe que el array resultante es Heroe[], sin ningún cast.
- Añade un predicado propio para el rol (esRolValido): comprueba no solo que el campo existe y es string, sino que su valor es uno de los literales conocidos.
- Ningún `as Heroe` manual: TypeScript infiere el tipo correcto en cada punto del flujo.
// Solución excelente — exhaustividad con never + aserciones `as` mínimas
//
// Añade una unión discriminada para los resultados de validación
// y un switch exhaustivo sobre Rol: si añades un rol nuevo a la unión,
// TypeScript te obliga a tratarlo en el switch.
// Las aserciones `as` son mínimas y están encapsuladas en validarHeroe:
// el código que consume los héroes ya validados no necesita ninguna.
// ── Tipos del dominio ───────────────────────────────────────────────
// Unión discriminada: el campo `tipo` distingue las variantes.
type Rol = "Daño" | "Tanque" | "Apoyo";
interface Heroe {
readonly nombre: string;
readonly rol: Rol;
partidas: number;
victorias: number;
}
// ── Resultado de validación (unión discriminada) ─────────────────────
// En lugar de devolver Heroe | null, distinguimos éxito/fallo con `tipo`.
type ResultadoValidacion =
| { tipo: "ok"; heroe: Heroe }
| { tipo: "error"; motivo: string };
// ── 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",
{
nombre: "Widowmaker",
rol: "Categoría Desconocida",
partidas: 50,
victorias: 30,
},
];
// ── Helper: comprobar que el campo es un string ─────────────────────
function esCampoString(obj: Record<string, unknown>, campo: string): boolean {
return campo in obj && typeof obj[campo] === "string";
}
// ── Helper: comprobar que el campo es un número ─────────────────────
function esCampoNumero(obj: Record<string, unknown>, campo: string): boolean {
return campo in obj && typeof obj[campo] === "number";
}
// ── Helper: comprobar que el rol es válido ──────────────────────────
// La función devuelve `valor is Rol`: predicado de tipo.
function esRolValido(valor: string): valor is Rol {
// Comparamos contra cada literal: si añades uno a Rol y olvidas
// actualizar este switch, TypeScript no te avisa aquí —para eso
// usamos el truco de never en procesarRol más abajo—.
return valor === "Daño" || valor === "Tanque" || valor === "Apoyo";
}
// ── Exhaustividad con never ──────────────────────────────────────────
// Esta función solo se puede llamar con un valor de tipo never.
// Si el switch cubre todos los casos de Rol, TypeScript sabe que
// este código es inalcanzable. Si añades un rol y no lo cubres,
// el tipo del argumento ya no es never y TypeScript marca error.
function casoImposible(valor: never): never {
throw new Error("Caso no cubierto: " + String(valor));
}
// ── Descripción de un rol (switch exhaustivo) ───────────────────────
function descripcionRol(rol: Rol): string {
switch (rol) {
case "Daño":
// Rol de daño: el equipo que elimina rivales.
return "elimina rivales";
case "Tanque":
// Rol de tanque: abre camino y absorbe daño.
return "abre camino y absorbe daño";
case "Apoyo":
// Rol de apoyo: cura y mejora al equipo.
return "cura y mejora al equipo";
default:
// Si llegamos aquí, hay un caso no cubierto: TypeScript lo detecta.
return casoImposible(rol);
}
}
// ── Type guard + validación con resultado discriminado ───────────────
// Devuelve ResultadoValidacion en lugar de boolean:
// el caso ok lleva el Heroe listo para usar; el caso error, el motivo.
function validarHeroe(dato: unknown): ResultadoValidacion {
// typeof + null check: dato debe ser un objeto.
if (typeof dato !== "object" || dato === null) {
return { tipo: "error", motivo: "el dato no es un objeto" };
}
// Desde aquí TypeScript sabe que dato es object.
// Lo tratamos como Record para acceder a sus propiedades con seguridad.
const obj = dato as Record<string, unknown>;
// Comprobamos cada campo con los helpers.
if (!esCampoString(obj, "nombre")) {
return { tipo: "error", motivo: "falta nombre o no es string" };
}
if (!esCampoString(obj, "rol")) {
return { tipo: "error", motivo: "falta rol o no es string" };
}
if (!esCampoNumero(obj, "partidas")) {
return { tipo: "error", motivo: "falta partidas o no es number" };
}
if (!esCampoNumero(obj, "victorias")) {
return { tipo: "error", motivo: "falta victorias o no es number" };
}
// Comprobamos que el rol es uno de los valores conocidos.
const rolCrudo = obj.rol as string;
if (!esRolValido(rolCrudo)) {
return { tipo: "error", motivo: "rol desconocido: " + rolCrudo };
}
// En este punto TypeScript sabe que todos los campos son del tipo
// correcto. Construimos el Heroe directamente sin `as`.
const heroe: Heroe = {
nombre: obj.nombre as string,
rol: rolCrudo,
partidas: obj.partidas as number,
victorias: obj.victorias as number,
};
return { tipo: "ok", heroe };
}
// ── Procesar los datos ───────────────────────────────────────────────
const resultados = datosApi.map(validarHeroe);
// Separamos éxitos de errores con el campo discriminante `tipo`.
const equipo: Heroe[] = [];
const errores: string[] = [];
for (const resultado of resultados) {
// Narrowing por el campo tipo: TypeScript sabe exactamente qué hay dentro.
if (resultado.tipo === "ok") {
equipo.push(resultado.heroe);
} else {
errores.push(resultado.motivo);
}
}
// ── Mostrar resultados ───────────────────────────────────────────────
for (const heroe of equipo) {
const winrate = ((heroe.victorias / heroe.partidas) * 100).toFixed(1);
// Usamos descripcionRol: si añadimos un Rol nuevo, el switch exhaustivo
// nos avisa en tiempo de compilación, antes de que llegue a producción.
console.log(
heroe.nombre + " [" + descripcionRol(heroe.rol) + "]: " + winrate + "%",
);
}
console.log("Equipo valido: " + equipo.length + " heroes");
console.log("Datos rechazados: " + errores.length);
for (const e of errores) {
console.log(" - " + e);
} Por qué es mejor que el anterior
- La función de validación devuelve una unión discriminada (ResultadoValidacion) que separa éxito de error con un campo tipo: el narrowing en el bucle es limpio y sin comprobaciones ad hoc.
- El switch sobre Rol usa casoImposible(rol) en el default: si alguien añade "Soporte" a Rol y olvida el case, TypeScript marca error porque el argumento ya no es never.
- Las aserciones `as` son mínimas y están encapsuladas en validarHeroe: `as Record<string, unknown>` para poder acceder a las propiedades del objeto unknown, y los `as string`/`as number` al construir el Heroe una vez todos los campos están verificados. El código que consume los héroes ya validados no necesita ninguna aserción.