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.
Respuesta corta
fetchpuede resolver la Promise con un404o500; compruebaresponse.okantes 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 comook: trueuok: false.- Usa
unknownen la frontera y un validador en runtime antes de afirmar que el resultado esT.
¿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.