Una función de servicio puede fallar de dos maneras que su llamador debe tratar de forma distinta. Una solicitud puede ser inválida, un registro puede no existir o una regla de negocio puede impedir la operación. Esos son resultados esperados del dominio. En cambio, una conexión a la base de datos que se cayó, una premisa rota o un bug de programación no son una decisión normal para el llamador.

Usa Result<T, E> para el primer grupo y deja que el segundo lance una excepción. No es una regla de estilo para todos los proyectos. Hace explícito el contrato: el tipo describe aquello sobre lo que el llamador puede decidir, mientras que throw conserva la interrupción de un camino que necesita diagnóstico o un manejo de nivel superior.

Este artículo es un spoke de la guía sobre cómo tipar respuestas de fetch con éxito y error en TypeScript. Esa guía cubre los resultados de red y JSON. Si la entrada todavía no se ha comprobado, consulta cómo validar JSON externo antes de usarlo en TypeScript. Esta página sube una capa y pregunta qué debe prometer el servicio después de que un adaptador haya tratado esas entradas.

No presento experiencia de producción ni una comparación de bibliotecas. Compilé una fixture local con TypeScript 5.9.3 usando strict y skipLibCheck para comprobar el narrowing de Result y la forma del ejemplo. La fixture no mide rendimiento ni demuestra que una política sirva para todos los dominios.

Diagrama que compara Result para fallos esperados, throw y catch para fallos inesperados y la traducción en el límite de la API.

Respuesta breve

  • Devuelve Result<T, E> cuando el fallo forma parte del resultado sobre el que el llamador puede decidir.
  • Lanza una excepción cuando la función no puede ofrecer una recuperación local segura o cuando se rompió una premisa de implementación.
  • En un límite HTTP o de una cola, traduce los errores de dominio esperados a respuestas del protocolo y deja visibles los fallos inesperados para la observabilidad.
  • Usa un error etiquetado y conserva cause; no conviertas todo fallo en Result<T, Error> ni captures todo con un único mensaje.

La diferencia es lo que el llamador puede decidir

La primera pregunta no es si Result parece más funcional o si try/catch parece más idiomático. Pregunta si el llamador tiene una decisión normal para ese caso.

Situación en el servicio ¿Puede decidir el llamador? Canal que suele comunicarlo mejor
Entrada que infringe una regla de negocio Sí, puede informar al cliente o corregir la entrada Result<T, E>
Registro no encontrado, cuando forma parte del contrato Sí, puede devolver 404, crear el registro o seguir otro camino Result<T, E>
Conflicto de estado conocido Sí, puede devolver 409 o pedir una lectura nueva Result<T, E>
Caída de la base de datos o de una dependencia No existe una decisión local segura que complete la solicitud excepción con contexto
Invariante imposible o bug de programación No es un resultado esperado del dominio excepción, alerta e investigación

La clasificación depende del contrato. NotFound puede ser un Result al buscar un usuario y una excepción en una rutina interna que recibió un ID que debería existir. El mismo problema externo puede ser esperado para un adaptador e inesperado para la regla de negocio que lo llama.

El Handbook de TypeScript sobre narrowing documenta cómo un campo literal compartido permite reducir una unión discriminada a una variante. Eso convierte a Result en un contrato comprobable, en vez de un objeto con dos campos opcionales.

Cómo modelar un Result para el servicio

Empieza con dos variantes que no puedan existir al mismo tiempo. El discriminante ok indica al compilador y al lector qué campo está disponible.

type Success<T> = {
  ok: true;
  value: T;
};

type Failure<E> = {
  ok: false;
  error: E;
};

type Result<T, E> = Success<T> | Failure<E>;

type PlaceOrderError =
  | { kind: "not-found"; orderId: string }
  | { kind: "conflict"; reason: "already-closed" }
  | { kind: "invalid-input"; field: "quantity" };

type Order = {
  id: string;
  status: "created" | "closed";
};

El error también necesita un discriminante. Result<Order, Error> solo dice que existe algún objeto de error. PlaceOrderError ofrece al consumidor un conjunto de casos que puede mapear a una decisión, como 404, 409 o 422. Esa lista no promete que ninguna excepción pueda escapar. Es la parte esperada del contrato.

Un servicio puede usar esta unión sin otra biblioteca:

type OrderRepository = {
  load(orderId: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
};

async function placeOrder(
  orderId: string,
  quantity: number,
  repository: OrderRepository,
): Promise<Result<Order, PlaceOrderError>> {
  if (!Number.isInteger(quantity) || quantity < 1) {
    return {
      ok: false,
      error: { kind: "invalid-input", field: "quantity" },
    };
  }

  const order = await repository.load(orderId);

  if (!order) {
    return { ok: false, error: { kind: "not-found", orderId } };
  }

  if (order.status === "closed") {
    return {
      ok: false,
      error: { kind: "conflict", reason: "already-closed" },
    };
  }

  await repository.save(order);
  return { ok: true, value: order };
}

El ejemplo es ilustrativo: la cantidad no cambia el pedido y la persistencia no está conectada a una base de datos. Lo importante para el contrato es que las entradas y los estados esperados devuelven una variante explícita. Si repository.load lanza una excepción porque la base de datos no está disponible, este Result no oculta el fallo.

¿Cuándo es más clara una excepción?

throw detiene la función actual y busca el primer catch en la pila de llamadas, como explica la referencia de MDN para la instrucción throw. Usa esa interrupción cuando continuar con un valor normal podría hacer que el llamador creyera que el trabajo terminó.

Una excepción no tiene que ser un mensaje sin contexto. Al añadir contexto, conserva la causa original:

class UnexpectedServiceError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = "UnexpectedServiceError";
  }
}

async function saveWithContext(
  order: Order,
  repository: OrderRepository,
): Promise<void> {
  try {
    await repository.save(order);
  } catch (cause) {
    throw new UnexpectedServiceError("order repository failed", { cause });
  }
}

La propiedad cause conserva la causa original cuando un error recibe un mensaje más útil, como explica la documentación de MDN sobre Error.cause. Un log puede registrar el error original y el contexto del servicio, mientras que la respuesta pública omite los detalles de infraestructura.

No captures todo solo para devolver un Result. Si una caída de la base de datos se convierte en { ok: false, error: ... } sin una decisión clara para el llamador, el tipo parece más uniforme, pero la señal del incidente es más débil. El contrato debe decir quién puede recuperarse, no solo ocultar la pila.

El límite de la API traduce los dos caminos

El controller o handler conoce el protocolo externo. Puede convertir un fallo esperado en HTTP y dejar que un fallo inesperado llegue al middleware de errores, al logger o al mecanismo de retry adecuado. Esta división encaja en la arquitectura Model-Controller-Service para backends TypeScript siempre que el servicio no conozca el formato final de la respuesta.

function statusFor(error: PlaceOrderError): number {
  switch (error.kind) {
    case "invalid-input":
      return 422;
    case "not-found":
      return 404;
    case "conflict":
      return 409;
  }
}

async function handlePlaceOrder(
  orderId: string,
  quantity: number,
  repository: OrderRepository,
): Promise<Response> {
  try {
    const result = await placeOrder(orderId, quantity, repository);

    if (!result.ok) {
      return Response.json(
        { error: result.error.kind },
        { status: statusFor(result.error) },
      );
    }

    return Response.json(result.value, { status: 200 });
  } catch (error) {
    console.error("unexpected place-order failure", error);
    return Response.json(
      { error: "internal-error" },
      { status: 500 },
    );
  }
}

El if (!result.ok) hace narrowing hacia Failure<PlaceOrderError>. El switch también deja visible la política del protocolo. Si alguien añade una nueva variante de error, el retorno explícito y las comprobaciones del compilador hacen fácil encontrar el punto de revisión.

Este handler no es un modelo universal de middleware. Un framework puede tener ya un traductor global de excepciones, en cuyo caso el servicio puede dejar escapar UnexpectedServiceError y el handler puede devolver la respuesta 500 de forma centralizada. La regla permanece: traduce los errores de dominio esperados donde conoces el protocolo y no conviertas un fallo operativo en un éxito parcial.

¿Cómo elegir sin añadir ceremonia?

Usa estas preguntas para cada fallo que cruza el límite del servicio:

  1. ¿Puede ocurrir con una entrada válida o en un estado normal del dominio?
  2. ¿Tiene el llamador una acción definida, como corregir datos, devolver 404 o repetir con otra versión?
  3. ¿Necesita el consumidor distinguirlo de otros fallos para elegir el flujo?
  4. Si la función devuelve un error, ¿alguien puede seguir observando la causa, el request ID y la operación que falló?

Si las tres primeras respuestas son sí, Result<T, E> suele hacer más honesto el contrato. Si la última respuesta es no, el problema no es elegir entre Result y excepciones. Falta la política de diagnóstico.

El camino híbrido suele ser el más sencillo en aplicaciones TypeScript:

  • los adapters convierten respuestas externas y validaciones conocidas en errores de dominio;
  • los services devuelven Result para las decisiones normales del caso de uso;
  • los fallos inesperados conservan su causa y suben como excepciones;
  • los controllers traducen resultados de dominio a HTTP, mensajes de cola o eventos;
  • las pruebas cubren cada variante sin tratar un fallo inesperado como una salida válida.

Esta separación también ayuda a los coding agents y a los revisores: la firma muestra el camino que debe tratarse, mientras que un catch amplio no se convierte en permiso para ignorar errores. El tipo no reemplaza las pruebas, los logs ni los límites de permisos, pero reduce la parte implícita de la conversación.

¿Qué probar antes de adoptar la política?

La prueba más útil no mide la velocidad de una excepción. Comprueba que el contrato no cambió en silencio:

  • una entrada inválida devuelve la variante de error esperada;
  • un registro ausente no llama a persistencia;
  • un conflicto devuelve su discriminante y el estado HTTP correspondiente;
  • una excepción del repositorio no se convierte en un Result exitoso;
  • el handler no expone el mensaje ni la cause de una dependencia;
  • una nueva variante de PlaceOrderError obliga a revisar el switch.

La fixture de este artículo se compiló con strict, noEmit, target: ES2022 y lib: ["ES2022", "DOM"]. Comprueba la forma de los ejemplos y el narrowing, pero no ejecuta una base de datos ni simula un servidor HTTP. En un proyecto real, coloca estos casos en el runner de pruebas del repositorio e incluye el fallo del adaptador, de la base de datos y del protocolo que exista realmente en el sistema.

Fuentes y límites

El comportamiento de narrowing usado por Result procede del Handbook de TypeScript sobre narrowing. El flujo de throw y catch procede de la referencia de MDN para throw, y la conservación de la causa procede de la referencia de MDN para Error.cause. La referencia de MDN para Promise.prototype.catch describe el límite de rechazo que una función async entrega a su llamador.

Las elecciones 422, 404, 409 y 500 del ejemplo son una política ilustrativa, no una recomendación HTTP universal. No comparé bibliotecas como neverthrow o Effect, no medí overhead ni afirmo que exista una convención dominante en la comunidad. La asistencia de IA apoyó la investigación, la redacción, la generación de la imagen, la traducción y la revisión de consistencia; no ejecutó la fixture ni sustituyó la verificación de fuentes y la responsabilidad editorial.