Type guard y assertion function en TypeScript
1. Resumen
TypeScript es muy bueno en narrowing (restricción de tipos) con typeof, instanceof o ===. Cuando la lógica de validación es más compleja y la separas en una función aparte, el compilador ya no “verá” el resultado de la validación.
Este artículo presenta dos herramientas que resuelven ese problema: el type guard (value is T) y la assertion function (asserts value is T). Después de leerlo, sabrás cómo escribirlas, validar datos unknown de una API, escribir tests para ellas, y reconocer casos donde silenciosamente engañan al compilador.
2. Narrowing integrado y sus limitaciones
Usaremos un dominio de órdenes como ejemplo a lo largo del artículo:
type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };
type Order = PendingOrder | PaidOrder | CancelledOrder;
function describe(order: Order): string {
if (order.status === "paid") {
return `Orden ${order.id} pagada el ${order.paidAt.toISOString()}`;
}
return `Orden ${order.id} está en estado ${order.status}`;
}
Order es una discriminated union con el campo discriminador status. Dentro de la rama if, TypeScript sabe que order es PaidOrder, así que acceder a paidAt es válido.
Ahora quieres reutilizar esa condición y separarla en una función:
function isPaid(order: Order): boolean {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaid(order)) {
return `Pagada el ${order.paidAt.toISOString()}`;
// ^^^^^^ error
}
return "Sin pagar";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
Property 'paidAt' does not exist on type 'PendingOrder'.
La función isPaid retorna boolean. El tipo boolean no lleva información sobre order, así que el compilador no puede estrechar el tipo. El type guard fue creado para resolver exactamente esto.
3. Type guard definido por el usuario
3.1. Sintaxis value is T
Solo necesitas cambiar el tipo de retorno de boolean a un type predicate:
function isPaidOrder(order: Order): order is PaidOrder {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaidOrder(order)) {
return `Pagada el ${order.paidAt.toISOString()}`;
}
// aquí order: PendingOrder | CancelledOrder
return `Estado: ${order.status}`;
}
console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "Sin stock" }));
Pagada el 2026-10-01T00:00:00.000Z
Estado: cancelled
Explicación:
order is PaidOrdersignifica: si la función retornatrue, entoncesordertiene tipoPaidOrder.- En runtime, la función sigue retornando
true/false. La parteissolo existe en tiempo de compilación. - En la rama
false, TypeScript eliminaPaidOrderde la unión.orderse convierte enPendingOrder | CancelledOrder. - El nombre del parámetro en el predicate (
order) debe coincidir con el nombre de un parámetro de la función.
3.2. Filtrar arrays con type guard
El type guard es especialmente útil con Array.prototype.filter, porque filter tiene una sobrecarga que acepta un predicate:
const orders: Order[] = [
{ status: "pending", id: "A1", total: 50 },
{ status: "paid", id: "A2", total: 120, paidAt: new Date() },
{ status: "paid", id: "A3", total: 80, paidAt: new Date() },
];
const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);
console.log(revenue);
200
paidOrders tiene tipo PaidOrder[], no Order[]. Gracias a eso, todas las operaciones posteriores pueden acceder a paidAt sin necesidad de casting.
Desde TypeScript 5.5, el compilador infiere automáticamente el type predicate para funciones arrow simples. Por ejemplo, orders.filter(o => o.status === "paid") también retorna PaidOrder[]. Esta inferencia solo funciona cuando la expresión es suficientemente simple y la relación “true ⇔ pertenece al tipo” es exacta. Con lógica más compleja, aún necesitas escribir el type guard explícitamente.
3.3. Validar datos unknown
El caso de uso más práctico del type guard es validar datos externos, como JSON de una API:
interface Customer {
id: string;
name: string;
email: string;
}
function isCustomer(value: unknown): value is Customer {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"email" in value && typeof value.email === "string"
);
}
const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');
if (isCustomer(raw)) {
console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM
Desde TypeScript 4.9, el operador in estrecha value a object & Record<"id", unknown>. Por eso puedes escribir value.id sin necesidad de as any. Cada campo se valida tanto su existencia como su tipo.
3.4. Trampa: el type guard miente
El compilador confía ciegamente en el type predicate. No verifica si el cuerpo de la función coincide realmente con la promesa:
function isCustomerBroken(value: unknown): value is Customer {
return typeof value === "object" && value !== null; // olvidas validar campos
}
const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
console.log(data.email.toUpperCase()); // compila OK, falla en runtime
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Hay una trampa más sutil en la rama false:
function isLargeAmount(value: string | number): value is number {
return typeof value === "number" && value > 1000;
}
function format(value: string | number) {
if (isLargeAmount(value)) return `Largo: ${value}`;
return value.toUpperCase(); // TS piensa que value: string
}
format(5); // TypeError: value.toUpperCase is not a function
La función retorna false para el número 5. TypeScript deduce que en la rama restante value no es number. Eso es incorrecto.
Nota: El type predicate es una relación bidireccional. Retornar
truedebe significar “pertenece al tipo T”. Retornarfalsedebe significar “no pertenece al tipo T”. No incluyas lógica de negocio (como> 1000) en el type guard. Sepárala en una funciónbooleanaparte.
4. Assertion function
4.1. asserts value is T
El type guard te obliga a envolver el código en if. Hay momentos en los que quieres un comportamiento más como “si es falso entonces lanza error, si no continúa”. Eso es trabajo de una assertion function:
function assertIsCustomer(value: unknown): asserts value is Customer {
if (!isCustomer(value)) {
throw new TypeError("Los datos no son un Customer válido");
}
}
function greet(payload: unknown): string {
assertIsCustomer(payload);
// desde aquí en adelante, payload: Customer
return `Hola ${payload.name}`;
}
console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
Hola Minh
TypeError: Los datos no son un Customer válido
Diferencias con el type guard:
- El tipo de retorno es
asserts value is Customer. El cuerpo de la función no retorna ningún valor, es decir, la función retornavoid. - La función lanza un error cuando la condición es falsa. Si la función retorna normalmente, TypeScript asume que la condición es verdadera.
- El tipo se estrecha para todo el código posterior a la llamada, no solo dentro de un bloque
if.
4.2. asserts condition
La segunda forma no está asociada a un tipo específico. Afirma que una expresión es truthy, similar a assert de Node.js:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function applyDiscount(order: Order, percent: number): number {
assert(order.status !== "cancelled", `Orden ${order.id} ha sido cancelada`);
// order: PendingOrder | PaidOrder → tiene total
assert(percent > 0 && percent <= 50, "El descuento debe estar en (0, 50]");
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180
Después de la línea assert(order.status !== "cancelled", ...), TypeScript estrecha order igual que si hubieras escrito if. Por eso order.total es válido, aunque CancelledOrder no tenga el campo total.
4.3. Error TS2775: falta type annotation explícita
Este es el error más frecuente al escribir assertion function como arrow function:
const assertPositive = (n: number): asserts n => {
if (n <= 0) throw new Error("Debe ser positivo");
};
assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.
El compilador necesita conocer el tipo de assertPositive en el punto de declaración, no puede confiar en inferencia. Tienes dos formas de solucionarlo. La primera es usar function declaration. La segunda es anotar explícitamente el tipo de la variable:
type AssertPositive = (n: number) => asserts n;
const assertPositive: AssertPositive = (n) => {
if (n <= 0) throw new Error("Debe ser positivo");
};
Esta regla también aplica a métodos llamados a través de un objeto, por ejemplo validator.assertCustomer(x). La variable validator debe tener un tipo declarado explícitamente, como const validator: CustomerValidator = new CustomerValidator().
Nota: La assertion function no puede ser
async. El tipoPromise<asserts x is T>no existe. Si necesitas validación asincrónica, hazawaitpara obtener los datos primero, luego llama a la assertion function sincrónica sobre el resultado.
5. Escribir tests para type guard y assertion function
Es lógica en runtime, así que necesitas tests como cualquier otra función. El ejemplo siguiente usa Vitest:
npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";
describe("isCustomer", () => {
it("retorna true con un object válido", () => {
expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
});
it.each([
null,
"C1",
{ id: "C1", name: "Lan" },
{ id: 1, name: "Lan", email: "lan@example.com" },
])("retorna false con %j", (input) => {
expect(isCustomer(input)).toBe(false);
});
});
describe("assertIsCustomer", () => {
it("lanza TypeError cuando los datos son inválidos", () => {
expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
});
it("estrecha el tipo cuando los datos son válidos", () => {
const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
assertIsCustomer(payload);
expect(payload.email).toBe("lan@example.com"); // compila gracias al narrowing
});
});
npx vitest run
✓ customer.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)
Presta atención a los casos negativos: campos faltantes, tipos incorrectos, null, valores primitivos. La mayoría de bugs en type guard están en casos donde retorna true cuando debería retornar false, como en el ejemplo de la sección 3.4. El último test también actúa como una prueba en tiempo de compilación. Si alguien cambia la firma de la función a void, el archivo de test no compilará.
6. ¿Type guard o assertion function?
| Situación | Usar |
|---|---|
| Los datos inválidos son normales, necesitas ramificar el manejo | Type guard |
Filtrar arrays, find, combinar con filter |
Type guard |
| Los datos inválidos son un error de programación o violación de contrato, debes detener | Assertion function |
Validación al inicio de función (precondition), evitar if anidados |
Assertion function |
Las dos herramientas frecuentemente trabajan juntas. Escribes el type guard como el núcleo, luego lo envuelves en una assertion function, como assertIsCustomer llama a isCustomer. Así la lógica de validación está en un solo lugar.
Cuándo NO debes escribirlas tú:
- Schema grande o anidado profundamente. Escribir a mano
isCustomerpara un objeto con 30 campos y arrays anidados es muy fácil que se te escape algo. Una librería de validación como Zod o Valibot genera el tipo y la validación desde el mismo schema, así nunca pueden divergir. - Reemplazo para
as. Un type guard con cuerpo vacío (return true) no es más seguro quevalue as Customer. Solo oculta el problema de forma más elegante. - Discriminated union simple. Si
if (order.status === "paid")ya es suficientemente claro, no extraigas una función solo para tenerla.
7. Conclusión
El type guard (value is T) te permite encapsular la lógica de validación de tipo en una función manteniendo el narrowing donde se llama. Es especialmente efectivo con if y filter. La assertion function (asserts value is T o asserts condition) estrecha el tipo para todo el código posterior lanzando un error cuando la condición es falsa. Es ideal para validar precondiciones y datos de entrada.
Ambas son promesas que el compilador no verifica. Por eso recuerda tres cosas:
- Mantén la relación “true ⇔ pertenece al tipo”.
- Declara explícitamente el tipo en assertion functions.
- Testea cuidadosamente los casos negativos.