fetch devuelve una Response, no el objeto que espera tu regla de negocio. Un 404 tampoco rechaza la Promise por sí solo. Si una función auxiliar convierte todo en T con as User, el compilador deja de mostrar la frontera donde los datos externos todavía no se han comprobado.

El patrón pequeño es una unión discriminada: { ok: true, data } para el éxito y { ok: false, error } para el fallo. El error puede distinguir un estado HTTP, un fallo de la petición, un JSON inválido y datos que no pasaron la validación. Así, cada consumidor debe elegir un camino antes de usar el valor.

Esto complementa la validación de JSON externo antes de usarlo en TypeScript. Ese artículo se centra en la validación en runtime; este modela el resultado completo para que las llamadas no escondan los fallos en excepciones genéricas.

Diagrama que muestra una respuesta de fetch pasando por una comprobación de tipos y separándose en éxito, error HTTP o fallo de petición y JSON.

Respuesta corta

  • fetch puede resolver la Promise con un 404 o 500; comprueba response.ok antes de leer los datos de la aplicación.
  • response.json() sigue siendo asíncrono y puede fallar después de que lleguen los headers.
  • Usa Result<T, E> con un discriminante literal como ok: true u ok: false.
  • Usa unknown en la frontera y un validador en runtime antes de afirmar que el resultado es T.

¿Por qué fetch no es un resultado de negocio?

La Promise de fetch representa una respuesta de red, no el éxito de tu operación de aplicación. La documentación de fetch en MDN explica que la Promise se resuelve con una Response y no se rechaza solo porque el servidor haya respondido con 404 o 504. La propiedad ok indica un estado entre 200 y 299.

Hay una segunda etapa. response.json() lee el cuerpo y devuelve otra Promise. El servidor puede enviar headers válidos y después entregar un JSON mal formado, o una forma diferente de la que espera tu código. Una función auxiliar que solo captura la llamada a fetch no representa todos los caminos que el consumidor debe manejar.

Response tampoco conoce User, Order ni ningún otro contrato de la aplicación. Una interfaz de TypeScript describe lo que declaraste, pero no inspecciona los bytes recibidos de la red. La misma diferencia entre declaración y prueba aparece cuando tipas datos añadidos por middleware de Express.

¿Cómo modelar éxito y fallo con una unión discriminada?

Una unión discriminada usa una propiedad compartida con valores literales distintos para cada variante. El handbook de narrowing de TypeScript muestra que comparar ese discriminante reduce la unión al miembro compatible. En una respuesta, ok: true ofrece data; ok: false ofrece error.

El tipo genérico siguiente mantiene separados el valor de éxito y el valor de fallo. El ejemplo es ilustrativo y debe comprobarse con tsc --strict en el proyecto que lo adopte:

type Ok<T> = {
  ok: true;
  data: T;
};

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

type Result<T, E> = Ok<T> | Err<E>;

function showUser(result: Result<User, FetchError>) {
  if (result.ok) {
    return result.data.name;
  }

  return `request failed: ${result.error.kind}`;
}

No uses ok: boolean en las dos variantes. Si el discriminante es solo un boolean, el compilador pierde la relación entre data y error. Tampoco coloques ambos campos como propiedades opcionales de un único objeto. Esa forma permite estados como { ok: true, error: ... } y exige más comprobaciones manuales.

¿Cómo separar HTTP, petición y JSON inválido?

Modela los fallos que el consumidor puede manejar de manera distinta. Un estado HTTP puede convertirse en un mensaje para el usuario, un fallo de red puede permitir un reintento posterior y un JSON inválido puede exigir avisar al proveedor. Una unión de errores conserva esa diferencia sin obligar a cada llamada a analizar una cadena de texto.

type FetchError =
  | { kind: "http"; status: number }
  | { kind: "request"; cause: unknown }
  | { kind: "invalid-json"; cause: unknown }
  | { kind: "invalid-data"; message: string };

type Validator<T> = (value: unknown) => value is T;

export async function getJson<T>(
  input: RequestInfo | URL,
  isT: Validator<T>,
): Promise<Result<T, FetchError>> {
  let response: Response;

  try {
    response = await fetch(input);
  } catch (cause) {
    return { ok: false, error: { kind: "request", cause } };
  }

  if (!response.ok) {
    await response.body?.cancel().catch(() => undefined);
    return { ok: false, error: { kind: "http", status: response.status } };
  }

  let value: unknown;
  try {
    value = await response.json();
  } catch (cause) {
    return { ok: false, error: { kind: "invalid-json", cause } };
  }

  if (!isT(value)) {
    return {
      ok: false,
      error: { kind: "invalid-data", message: "response shape is not valid" },
    };
  }

  return { ok: true, data: value };
}

El resultado de response.json() se trata como unknown de forma deliberada. El validador es la prueba en runtime que lo convierte en T. Si la aplicación usa Zod, Valibot, JSON Schema o un cliente generado, sustituye solo isT. No elimines la frontera porque el compilador no puede leer la respuesta que llegó de la red.

El body.cancel() del camino HTTP también es intencional. Si no vas a consumir el cuerpo de una respuesta que ya sabes que es inválida, cancélalo o consúmelo según el cliente y el protocolo que uses. Esto no convierte automáticamente un estado HTTP en una excepción. Solo evita dejar una respuesta sin consumidor.

¿Cómo consumir el resultado sin casts?

El consumidor debe estrechar primero y acceder a la propiedad después. Un switch sobre kind deja visibles los caminos de error y permite una comprobación exhaustiva. Este ejemplo también es ilustrativo:

function assertNever(value: never): never {
  throw new Error(`unhandled result: ${String(value)}`);
}

function describe(result: Result<User, FetchError>): string {
  if (result.ok) {
    return `Hello, ${result.data.name}`;
  }

  switch (result.error.kind) {
    case "http":
      return `upstream returned ${result.error.status}`;
    case "request":
      return "the request could not be completed";
    case "invalid-json":
      return "the upstream response was not valid JSON";
    case "invalid-data":
      return result.error.message;
    default:
      return assertNever(result.error);
  }
}

Si añades una nueva variante a FetchError, assertNever hace que tsc señale este switch hasta que el consumidor elija un comportamiento. Esto no promete que el sistema nunca vaya a fallar. Hace que el conjunto de decisiones sea conocido para el código que compila.

No desestructures data y error antes de comprobar ok solo para acortar el código. En versiones y formas diferentes, la relación de narrowing resulta menos evidente. Conserva el objeto hasta comparar el discriminante y después accede a la variante estrechada.

¿Cómo comprobar que el tipo protege la frontera?

Empieza por el compilador. Ejecuta npx tsc --noEmit con las mismas opciones que CI y confirma que result.data falla en la rama ok: false, que result.error falla en la rama de éxito y que el switch informa de una nueva variante. Si el proyecto todavía no tiene una configuración de TypeScript, trata los fragmentos como ilustrativos hasta crear una fixture pequeña con strict: true.

Después prueba el comportamiento en cuatro casos: respuesta 2xx con forma válida, estado HTTP de error, cuerpo que no es JSON y JSON rechazado por el validador. Añade un fallo de conexión con un endpoint local o un mock del transporte. La prueba debe comprobar la variante y sus campos, no solo que una Promise terminó.

Para la capa de pruebas, aplica la misma idea que en probar la lógica de retry de JavaScript sin esperar: controla la dependencia externa y comprueba la transición observable. No conviertas la prueba en una demostración de que TypeScript validó en runtime. Los tipos desaparecen cuando el código se convierte en JavaScript.

¿Qué no resuelve este patrón?

Una unión Result no valida JSON por sí sola, elige un timeout, hace idempotente un POST, repite una llamada de forma segura ni garantiza que un servidor haya deshecho un efecto externo. Organiza el contrato de retorno. Retry, cancelación, telemetría e idempotencia siguen siendo decisiones de la capa que conoce la operación.

Tampoco tienes que sustituir todas las excepciones. Usa un resultado cuando el fallo esperado forma parte del contrato que el consumidor debe manejar. Reserva las excepciones para invariantes rotos o fallos que la capa actual no puede representar. Si una librería ya ofrece un tipo de resultado, adáptalo en la frontera en lugar de crear una segunda convención en cada llamada.

Preguntas frecuentes

¿fetch rechaza la Promise cuando recibe un 404?

No. La Promise normalmente se resuelve con una Response, y el consumidor debe examinar response.ok o response.status. La guía de Fetch de MDN separa el estado HTTP de fallos como una red caída o una URL inválida. Una función auxiliar puede convertir el estado en una variante http sin lanzar una excepción genérica.

¿Una interfaz de TypeScript valida el JSON recibido?

No. Una interfaz describe lo que el programa espera, pero no inspecciona un valor en runtime. Lee el cuerpo como unknown y usa un type guard o una librería de schemas para demostrar su forma antes de devolver T. El artículo sobre validar JSON externo muestra esta frontera con un objeto anidado.

¿Result<T, E> sustituye try/catch en todas partes?

No. Es útil cuando el éxito y el fallo forman parte del contrato que el consumidor debe manejar. Un error inesperado todavía puede lanzarse y ser capturado por una capa de infraestructura. El beneficio está en decidir, función por función, qué fallos esperados se convierten en datos y cuáles siguen siendo excepciones.

Conclusión

Tipar una respuesta de fetch exige separar lo que entregó la red de lo que la aplicación puede confiar. Comprueba primero el estado. Después lee el cuerpo, valida su forma y solo entonces produce T. Una unión discriminada lleva ese contrato al consumidor sin esconder fallos en any, as o mensajes sin estructura.

El patrón se mantiene pequeño cuando cada capa tiene una tarea: fetch transporta, el validador demuestra la forma y Result<T, E> deja explícitas las decisiones. Retry, cancelación y observabilidad pueden evolucionar después sin reescribir cómo cada llamada interpreta el éxito y el fallo.

Fuentes consultadas