Saltar al contenido

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.

4 min de lectura
Código TypeScript que muestra el manejo estructurado de errores con tipos Result y uniones discriminadas

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.

tstypescript
// ❌ 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.

tstypescript
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:

EscenarioEnfoqueRazonamiento
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)throwDebe fallar con ruido: es un bug
Fallo de infraestructura (timeout de red, disco lleno)throw en el límiteLa lógica de reintentos pertenece al borde
Validación de formulario visible para el usuarioResult<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.

tstypescript
// 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.

tstypescript
// 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.

tstypescript
// 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

  1. try/catch oculta errores al sistema de tipos — usa Result<T, E> para fallos esperados para que el compilador los rastree
  2. Las uniones discriminadas te dan errores tipados sin costo — no se requiere biblioteca, no hay sobrecarga en tiempo de ejecución
  3. Reserva throw para bugs genuinos del programador — si es una condición recuperable, debería ser un Result
  4. 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/catch de infraestructura
  5. Los errores tipados facilitan las pruebas — puedes devolver Result de error directamente sin simular comportamientos de throw
  6. Compón Results con utilidades — encadenar operaciones fallibles se mantiene legible con una utilidad andThen simple
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX