En esta página

Narrowing y type guards

14 min lectura TextoCap. 4 — Tipos avanzados

¿Qué es el narrowing?

Cuando declaras una variable de tipo string | number, TypeScript sabe que puede ser cualquiera de los dos. Narrowing es el proceso mediante el cual el compilador reduce (estrecha) ese tipo a uno más específico dentro de un bloque de código, basándose en las comprobaciones que tú escribes.

El resultado es que dentro de cada rama, TypeScript te ofrece el autocompletado y la seguridad de tipos del tipo concreto, sin necesidad de un cast explícito.


typeof narrowing

La comprobación más básica usa el operador typeof, que devuelve un string con el nombre del tipo primitivo:

function formatearValor(valor: string | number | boolean): string {
  if (typeof valor === "string") {
    // Aquí TypeScript sabe que valor es string
    return valor.toUpperCase();
  }

  if (typeof valor === "number") {
    // Aquí es number
    return valor.toFixed(2);
  }

  // Aquí es boolean (TypeScript lo deduce por exhaustividad)
  return valor ? "Sí" : "No";
}

console.log(formatearValor("hola"));  // "HOLA"
console.log(formatearValor(3.14159)); // "3.14"
console.log(formatearValor(false));   // "No"

Los tipos reconocidos por typeof son: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" y "function".


Truthiness narrowing

JavaScript tiene valores falsy: 0, "", null, undefined, NaN, false. TypeScript entiende las comprobaciones de veracidad:

function procesarTexto(texto: string | null | undefined): string {
  if (!texto) {
    // texto es null, undefined o string vacío
    return "(sin contenido)";
  }
  // Aquí texto es string (y no vacío)
  return texto.trim().toLowerCase();
}

También puedes usar el operador de coalescencia nula (??) para un narrowing más explícito:

function obtenerLongitud(valor: string | null): number {
  // Si valor es null, devuelve 0
  return (valor ?? "").length;
}

Equality narrowing

TypeScript estrecha tipos cuando comparas con === o !==:

type Direccion = "norte" | "sur" | "este" | "oeste";

function mover(dir: Direccion, pasos: number): string {
  if (dir === "norte" || dir === "sur") {
    return `Moviéndose ${pasos} pasos en el eje Y (${dir})`;
  }
  // Aquí TypeScript sabe que dir es "este" | "oeste"
  return `Moviéndose ${pasos} pasos en el eje X (${dir})`;
}

El operador `in`

Cuando trabajas con objetos de diferentes formas, el operador in verifica si una propiedad existe y TypeScript lo usa para estrechar:

interface Circulo {
  radio: number;
}

interface Rectangulo {
  ancho: number;
  alto: number;
}

type Forma = Circulo | Rectangulo;

function calcularArea(forma: Forma): number {
  if ("radio" in forma) {
    // forma es Circulo
    return Math.PI * forma.radio ** 2;
  }
  // forma es Rectangulo
  return forma.ancho * forma.alto;
}

console.log(calcularArea({ radio: 5 }));            // ~78.54
console.log(calcularArea({ ancho: 4, alto: 6 }));   // 24

instanceof narrowing

Para clases e instancias, instanceof es el mecanismo ideal:

class ErrorDeRed extends Error {
  constructor(
    public readonly statusCode: number,
    mensaje: string
  ) {
    super(mensaje);
    this.name = "ErrorDeRed";
  }
}

class ErrorDeValidacion extends Error {
  constructor(
    public readonly campo: string,
    mensaje: string
  ) {
    super(mensaje);
    this.name = "ErrorDeValidacion";
  }
}

function manejarError(error: unknown): string {
  if (error instanceof ErrorDeRed) {
    return `HTTP ${error.statusCode}: ${error.message}`;
  }

  if (error instanceof ErrorDeValidacion) {
    return `Campo "${error.campo}" inválido: ${error.message}`;
  }

  if (error instanceof Error) {
    return `Error genérico: ${error.message}`;
  }

  return "Error desconocido";
}

Discriminated unions

Las uniones discriminadas son el patrón más poderoso para el narrowing. Consisten en una unión de objetos que comparten una propiedad literal (el discriminador) con valores únicos por variante:

type EstadoPedido =
  | { estado: "pendiente"; fechaCreacion: Date }
  | { estado: "procesando"; fechaInicio: Date; operadorId: string }
  | { estado: "enviado"; fechaEnvio: Date; numeroSeguimiento: string }
  | { estado: "entregado"; fechaEntrega: Date; firma: string }
  | { estado: "cancelado"; motivo: string };

function describir(pedido: EstadoPedido): string {
  switch (pedido.estado) {
    case "pendiente":
      return `Pedido creado el ${pedido.fechaCreacion.toLocaleDateString()}`;
    case "procesando":
      return `En proceso desde el ${pedido.fechaInicio.toLocaleDateString()} por ${pedido.operadorId}`;
    case "enviado":
      return `Enviado el ${pedido.fechaEnvio.toLocaleDateString()}, seguimiento: ${pedido.numeroSeguimiento}`;
    case "entregado":
      return `Entregado el ${pedido.fechaEntrega.toLocaleDateString()}, firmado por: ${pedido.firma}`;
    case "cancelado":
      return `Cancelado: ${pedido.motivo}`;
  }
}

TypeScript verifica que el switch sea exhaustivo: si añades una nueva variante al tipo y olvidas manejarla, el compilador te lo advierte.

Verificación de exhaustividad explícita

Para casos donde usas if/else en lugar de switch, puedes forzar la verificación con el tipo never:

function verificarExhaustividad(valor: never): never {
  throw new Error(`Caso no manejado: ${JSON.stringify(valor)}`);
}

function procesarEstado(pedido: EstadoPedido): void {
  if (pedido.estado === "pendiente") {
    // ...
  } else if (pedido.estado === "procesando") {
    // ...
  } else {
    // Si olvidas algún caso, TypeScript marca error aquí
    verificarExhaustividad(pedido);
  }
}

Custom type guards con `is`

Un type guard personalizado es una función que devuelve un predicado de tipo (param is Tipo). Cuando la función retorna true, TypeScript sabe que param es del tipo indicado:

interface Usuario {
  id: number;
  nombre: string;
  rol: "admin" | "usuario";
}

interface Invitado {
  sessionId: string;
  expira: Date;
}

type Visitante = Usuario | Invitado;

// Type guard personalizado
function esUsuario(visitante: Visitante): visitante is Usuario {
  return "id" in visitante && "rol" in visitante;
}

function obtenerIdentificador(visitante: Visitante): string {
  if (esUsuario(visitante)) {
    // TypeScript sabe que es Usuario
    return `Usuario #${visitante.id} (${visitante.nombre})`;
  }
  // TypeScript sabe que es Invitado
  return `Invitado temporal: ${visitante.sessionId}`;
}

Los type guards son especialmente útiles cuando la lógica de discriminación es compleja y quieres encapsularla y reutilizarla.


Assertion functions

Una assertion function lanza un error si la condición no se cumple y le indica a TypeScript que puede confiar en el tipo después de la llamada:

function assertEsString(valor: unknown): asserts valor is string {
  if (typeof valor !== "string") {
    throw new TypeError(`Se esperaba string, se recibió ${typeof valor}`);
  }
}

function assertNoNulo<T>(
  valor: T,
  mensaje: string
): asserts valor is NonNullable<T> {
  if (valor === null || valor === undefined) {
    throw new Error(mensaje);
  }
}

// Uso:
function procesarConfiguracion(config: unknown): void {
  assertEsString(config);
  // A partir de aquí TypeScript sabe que config es string
  const partes = config.split(",");
  console.log("Opciones:", partes);
}

function obtenerUsuarioActual(id: number): Usuario | null {
  // Simulación de búsqueda
  return id === 1 ? { id: 1, nombre: "Ana", rol: "admin" } : null;
}

function cargarPerfil(id: number): string {
  const usuario = obtenerUsuarioActual(id);
  assertNoNulo(usuario, `Usuario con id ${id} no encontrado`);
  // Aquí TypeScript sabe que usuario no es null
  return usuario.nombre;
}

Ejemplo completo: manejador de respuesta API

// ──────────────────────────────────────────────────────────────
// Tipos de respuesta
// ──────────────────────────────────────────────────────────────
type RespuestaExitosa<T> = {
  tipo: "exito";
  datos: T;
  timestamp: number;
};

type RespuestaError = {
  tipo: "error";
  mensaje: string;
  codigo: number;
};

type RespuestaAPI<T> = RespuestaExitosa<T> | RespuestaError;

// ──────────────────────────────────────────────────────────────
// Type guard para distinguir variantes
// ──────────────────────────────────────────────────────────────
function esExitosa<T>(r: RespuestaAPI<T>): r is RespuestaExitosa<T> {
  return r.tipo === "exito";
}

// ──────────────────────────────────────────────────────────────
// Procesador genérico
// ──────────────────────────────────────────────────────────────
function procesarRespuesta<T>(
  respuesta: RespuestaAPI<T>,
  onExito: (datos: T) => void,
  onError: (codigo: number, mensaje: string) => void
): void {
  if (esExitosa(respuesta)) {
    onExito(respuesta.datos);
  } else {
    onError(respuesta.codigo, respuesta.mensaje);
  }
}

// ──────────────────────────────────────────────────────────────
// Simulación de uso
// ──────────────────────────────────────────────────────────────
interface Articulo {
  id: number;
  titulo: string;
  contenido: string;
}

const respuestaOk: RespuestaAPI<Articulo> = {
  tipo: "exito",
  datos: { id: 1, titulo: "Hola mundo", contenido: "Primer artículo" },
  timestamp: Date.now(),
};

const respuestaFallo: RespuestaAPI<Articulo> = {
  tipo: "error",
  mensaje: "Recurso no encontrado",
  codigo: 404,
};

procesarRespuesta(
  respuestaOk,
  (art) => console.log(`Artículo cargado: ${art.titulo}`),
  (cod, msg) => console.error(`Fallo ${cod}: ${msg}`)
);

procesarRespuesta(
  respuestaFallo,
  (art) => console.log(art.titulo),
  (cod, msg) => console.error(`Fallo ${cod}: ${msg}`)
);

Resumen de técnicas de narrowing

Técnica Ejemplo Mejor para
typeof typeof x === "string" Tipos primitivos
Truthiness if (valor) null, undefined, falsy
Equality x === "opcion" Literales y enums
in "propiedad" in obj Objetos con formas distintas
instanceof x instanceof Clase Instancias de clases
Discriminated union switch (x.tipo) Uniones con discriminador
Type guard (is) función(x): x is T Lógica compleja reutilizable
Assertion function asserts x is T Validaciones imperativas

En la siguiente lección explorarás los utility types de TypeScript: herramientas listas para usar que transforman tipos existentes sin necesidad de reescribirlos desde cero.

Prefiere discriminated unions sobre type guards manuales
Siempre que puedas agregar un campo discriminador (tipo, kind, tag) a tus uniones, hazlo. TypeScript lo entiende automáticamente y no necesitas escribir funciones is* adicionales.
typeof null === 'object'
El operador typeof devuelve 'object' para null, lo cual es un error histórico de JavaScript. Siempre comprueba `valor !== null` antes (o usa `valor == null` que cubre también undefined) cuando trabajes con tipos que incluyan null.