El narrowing es el proceso por el que TypeScript reduce el tipo de una variable dentro de un bloque de código en función de las comprobaciones que se han hecho. Las formas básicas typeof, instanceof, comprobaciones de nulo cubren la mayoría de casos, pero el narrowing avanzado incluye predicados de tipo con is, assertion functions con asserts, discriminated unions y el patrón de exhaustiveness checking con never. Estas herramientas juntas permiten que el compilador razone sobre la forma de los datos de manera que los tipos en runtime coincidan exactamente con los tipos en compilación.
Predicados de tipo: is
Una función que devuelve parametro is Tipo actúa como un type guard: cuando devuelve true, TypeScript considera que ese parámetro tiene el tipo indicado en el bloque que sigue a la llamada:
interface Perro { especie: "perro"; ladra(): void }
interface Gato { especie: "gato"; ronronea(): void }
type Animal = Perro | Gato;
// Sin predicado: TypeScript no puede estrechar el tipo
function esPerro(a: Animal): boolean {
return a.especie === "perro";
}
// Con predicado: TypeScript estrecha dentro del if
function esPerroGuard(a: Animal): a is Perro {
return a.especie === "perro";
}
function interactuar(a: Animal) {
if (esPerroGuard(a)) {
a.ladra(); // ? TypeScript sabe que es Perro
} else {
a.ronronea(); // ? TypeScript sabe que es Gato
}
}
Assertion functions: asserts
Una assertion function no devuelve un booleano: o lanza un error o aserta que el tipo es correcto. TypeScript entiende que si la función no lanza, el tipo ha quedado estrechado:
function assertEsString(valor: unknown): asserts valor is string {
if (typeof valor !== "string") {
throw new Error(`Se esperaba string, se recibió ${typeof valor}`);
}
}
function assertDefinido<T>(valor: T | null | undefined): asserts valor is T {
if (valor == null) throw new Error("Valor indefinido");
}
function procesar(input: unknown) {
assertEsString(input);
// Aquí TypeScript sabe que input es string
console.log(input.toUpperCase());
}
Discriminated unions
Una discriminated union (o tagged union) es un union cuyos miembros comparten una propiedad literal el discriminante que TypeScript usa para distinguirlos sin ambigüedad. El discriminante suele ser de tipo string literal:
type Exito = { tipo: "exito"; datos: string[] };
type Error = { tipo: "error"; codigo: number; mensaje: string };
type Cargando = { tipo: "cargando" };
type Estado = Exito | Error | Cargando;
function renderizar(estado: Estado): string {
switch (estado.tipo) {
case "exito":
return estado.datos.join(", "); // estado: Exito
case "error":
return `Error ${estado.codigo}: ${estado.mensaje}`; // estado: Error
case "cargando":
return "Cargando..."; // estado: Cargando
}
}
Exhaustiveness checking con never
Si en el futuro se añade un nuevo miembro al union, el compilador avisará en el punto donde no se gestiona. El patrón usa una variable de tipo never que ningún valor puede satisfacer como trampa:
type EstadoAmpliado = Estado | { tipo: "pausado" };
function renderizarExhaustivo(estado: EstadoAmpliado): string {
switch (estado.tipo) {
case "exito": return estado.datos.join(", ");
case "error": return estado.mensaje;
case "cargando": return "Cargando...";
// Si se omite "pausado", el default llega con tipo { tipo: "pausado" }
// que no es never ? error de compilación
default:
const _exhaustivo: never = estado;
throw new Error(`Estado no gestionado: ${JSON.stringify(_exhaustivo)}`);
}
}
Control flow analysis automático
TypeScript analiza el flujo de control y estrecha tipos sin que el programador haga nada explícito cuando usa comprobaciones estándar:
function longitud(valor: string | string[] | null): number {
if (valor === null) return 0;
// Aquí: string | string[]
if (typeof valor === "string") return valor.length;
// Aquí: string[]
return valor.reduce((acc, s) => acc + s.length, 0);
}
// Las asignaciones también estrechan:
let x: string | number = obtenerValor();
if (typeof x === "string") {
x = x.trim(); // x sigue siendo string dentro del bloque
}
// Aquí x es string | number otra vez
Predicados de tipo en Array.filter
const mezcla: (string | null | number)[] = ["a", null, 1, null, "b", 2];
// Sin predicado: tipo inferido como (string | null | number)[]
const sinNull1 = mezcla.filter(x => x !== null);
// Con predicado: tipo inferido como (string | number)[]
const sinNull2 = mezcla.filter((x): x is string | number => x !== null);
// Helper genérico:
function esDefinido<T>(valor: T | null | undefined): valor is T {
return valor != null;
}
const soloDatos = mezcla.filter(esDefinido); // (string | number)[]
El narrowing avanzado es lo que distingue el TypeScript idiomático del TypeScript que solo añade anotaciones de tipo. Cuando el compilador puede razonar sobre el flujo de control y las formas de los datos, el código se vuelve más seguro y los errores de tipo pasan a detectarse en compilación, no en producción.
Imagen: Pexels / Pixabay
