Manejo de errores en TypeScript: patrones que escalan
Deja atrás el caos de try-catch: uniones discriminadas, tipos Result y límites de error que hacen tu TypeScript más fiable y mantenible.

La mayoría de las bases de código en TypeScript manejan errores de la misma forma en que JavaScript siempre lo ha hecho: bloques try/catch por todas partes, errores capturados unknown convertidos a any, y la esperanza de que nada importante se escape. El sistema de tipos que debía protegerte está completamente ciego ante los caminos de fallo. Hay una mejor forma, y no requiere una biblioteca.
Por qué try-catch no escala
El problema fundamental de try/catch como estrategia principal de manejo de errores es que los errores se vuelven invisibles a nivel de tipos. La firma de una función promete que devuelve User, pero podría lanzar cinco excepciones diferentes. Quien la llama no tiene forma de saberlo sin leer la implementación.
// ❌ The signature lies — this can fail in at least three ways
async function getUser(id: string): Promise<User> {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) throw new Error("User not found");
return parseUser(row); // can also throw if schema changed
}
// ✅ The type signature tells the whole story
async function getUser(
id: string,
): Promise<Result<User, "NOT_FOUND" | "PARSE_ERROR" | "DB_ERROR">> {
try {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) return err("NOT_FOUND");
const parsed = parseUser(row);
if (!parsed.ok) return err("PARSE_ERROR");
return ok(parsed.value);
} catch {
return err("DB_ERROR");
}
}La segunda versión establece un contrato: quien llama debe manejar el fallo. El compilador lo exige.
Construyendo un tipo Result ligero
No necesitas fp-ts o neverthrow para la mayoría de las aplicaciones. Una implementación mínima cubre el 90% de los casos de uso reales.
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Type guard for direct discrimination
function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok;
}La unión discriminada significa que TypeScript estrecha automáticamente cuando verificas result.ok. Sin conversiones, sin aserciones as: el compilador rastrea en qué rama estás.
Cuándo lanzar vs. cuándo devolver
No todo fallo debe ser un Result. Mezclar ambos incorrectamente genera ruido. Usa esta heurística:
| Escenario | Enfoque | Razonamiento |
|---|---|---|
| Fallo esperado (no encontrado, error de validación) | Result<T, E> | Se espera que quien llame lo maneje |
| Bug del programador (desreferencia nula, suposición incorrecta) | throw | Debe fallar con ruido: es un bug |
| Fallo de infraestructura (timeout de red, disco lleno) | throw en el límite | La lógica de reintentos pertenece al borde |
| Validación de formulario visible para el usuario | Result<T, ValidationError[]> | Múltiples errores, retroalimentación estructurada |
El objetivo es que throw signifique "esto nunca debería pasar en un código correcto". Cuando ocurre, quieres que sea evidente y rastreable.
Componiendo Results
El verdadero poder aparece cuando encadenas operaciones que pueden fallar cada una. Sin una utilidad, esto se convierte en pattern matching anidado.
// Utility for chaining fallible operations
function andThen<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>,
): Result<U, E> {
if (!result.ok) return result;
return fn(result.value);
}
// Usage: each step can fail, errors propagate automatically
async function processOrder(
orderId: string,
): Promise<Result<Receipt, OrderError>> {
const order = await fetchOrder(orderId);
if (!order.ok) return order;
const inventory = await checkInventory(order.value.items);
if (!inventory.ok) return inventory;
const payment = await chargePayment(order.value.total);
if (!payment.ok) return payment;
return ok(generateReceipt(order.value, payment.value));
}Cada paso tiene un error tipado. Quien llama a processOrder ve una unión de todos los errores posibles y maneja cada uno explícitamente.
Límites de error en el borde
Mantén try/catch en los límites del sistema. Los manejadores HTTP, consumidores de colas y puntos de entrada CLI son el lugar correcto. Todo dentro son Result tipados que se propagan limpiamente.
// API route handler — the only place with try/catch
export async function POST(req: Request) {
try {
const body = await req.json();
const result = await processOrder(body.orderId);
if (!result.ok) {
const statusMap: Record<OrderError, number> = {
NOT_FOUND: 404,
INVENTORY_UNAVAILABLE: 409,
PAYMENT_FAILED: 402,
DB_ERROR: 500,
};
return Response.json(
{ error: result.error },
{ status: statusMap[result.error] ?? 500 },
);
}
return Response.json(result.value, { status: 201 });
} catch (e) {
// Truly unexpected — log and fail
console.error("Unhandled error in POST /orders", e);
return Response.json({ error: "INTERNAL_ERROR" }, { status: 500 });
}
}Los errores de infraestructura (try/catch) se quedan en el límite. Los errores de lógica de negocio (Result) se manejan explícitamente. La distinción hace que el código sea más fácil de razonar y probar.
Probando caminos de error
Un beneficio poco valorado de los tipos Result: hacen que los caminos de error sean trivialmente probables. No necesitas simular comportamientos de throw: solo devuelve el tipo de error apropiado.
// Testing is clean — no try/catch, no mock implementations that throw
describe("processOrder", () => {
it("returns INVENTORY_UNAVAILABLE when stock is depleted", async () => {
mockFetchOrder.mockResolvedValue(ok(sampleOrder));
mockCheckInventory.mockResolvedValue(err("INVENTORY_UNAVAILABLE" as const));
const result = await processOrder("order-123");
expect(result.ok).toBe(false);
expect(result.ok === false && result.error).toBe("INVENTORY_UNAVAILABLE");
});
});Compara esto con probar código que lanza: necesitas expect(...).rejects.toThrow(...), lo que pierde información de tipos y requiere ceremonia extra.
Conclusiones clave
try/catchoculta errores al sistema de tipos — usaResult<T, E>para fallos esperados para que el compilador los rastree- Las uniones discriminadas te dan errores tipados sin costo — no se requiere biblioteca, no hay sobrecarga en tiempo de ejecución
- Reserva
throwpara bugs genuinos del programador — si es una condición recuperable, debería ser unResult - Mantén los límites de error en los bordes del sistema — los manejadores HTTP y consumidores de colas son el lugar correcto para
try/catchde infraestructura - Los errores tipados facilitan las pruebas — puedes devolver Result de error directamente sin simular comportamientos de
throw - Compón Results con utilidades — encadenar operaciones fallibles se mantiene legible con una utilidad
andThensimple


