Skip to content
TypeWire

@tahanabavi/typefetch

documents v1.10.0

The typed HTTP client every other package is built around.

Error Handling

TypeFetch normalizes errors into RichError.

ts
client.onError((error) => {
console.error(error.message);
console.error(error.status);
console.error(error.code);
});

RichError may include:

ts
{
message: string;
status?: number;
code?: string;
title?: string;
detail?: string;
errors?: Record<string, string[]>;
}

Handled error types include:

  • HTTP errors

  • Validation errors

  • Wrapped API errors

  • Missing token errors

  • Network errors

  • Timeout errors

  • Retry exhaustion

onError fires exactly once per failed request — after retries are exhausted, not once per attempt.

Changed in v1.9.0. It previously fired once per layer that saw the error on its way out: twice for a plain HTTP failure, and once per attempt plus one when retries were configured (a request with maxRetries: 2 called it four times). If you were compensating for the duplicates, remove that.

Example:

ts
try {
await api.user.getUser({
path: { id: "missing" },
});
} catch (error) {
if (error instanceof RichError) {
console.error(error.status, error.message);
}
}

Non-JSON error bodies

A failed response is read as text and then parsed, never with res.json() directly. Real failures are frequently not JSON — a 502 from a proxy is an HTML page, a 401 from a gateway is often empty — and the HTTP status is the single most useful fact about them.

  • JSON body → parsed, and data typed against the endpoint's errors map.

  • Anything else → the raw text lands in error.detail.

  • Empty body → still a RichError carrying status.

message falls back through the body's message, then statusText, then HTTP <status>.

This applies whatever the endpoint's responseType is: a blob endpoint still reports its 404 as JSON.

Changed in v1.9.0. Before this, the body was decoded with res.json() before the status was checked, so any non-JSON failure threw a raw SyntaxError and the status never reached the caller. If you have a catch that special-cases that SyntaxError, it now receives a RichError instead.


Typed Error Responses

An endpoint declares its success (2xx) body via response. You can optionally declare the body of error responses with an errors map keyed by HTTP status code. Each value is a plain Zod schema.

ts
createUser: {
method: "POST",
path: "/users",
request: z.object({ body: z.object({ email: z.string().email() }) }),
response: z.object({ id: z.string() }),
errors: {
409: z.object({ code: z.literal("EMAIL_TAKEN"), conflictField: z.string() }),
422: z.object({ code: z.literal("INVALID"), issues: z.array(z.string()) }),
},
}

This is fully backward compatible: endpoints without errors behave exactly as before.

Typed error bodies on the client

When a request fails and a schema is declared for the response status, the client parses the body and attaches it to RichError.data. Use the isContractError guard to narrow a caught error to a specific status and get a fully typed data.

ts
import { isContractError } from "@tahanabavi/typefetch";
import { contracts } from "./contracts";

try {
await api.user.createUser({ body: { email } });
} catch (e) {
if (isContractError(contracts.user.createUser, e, 409)) {
e.data.conflictField; // fully typed from the 409 schema
}
}

isContractError(endpoint, error, status) returns true only when error is a RichError whose status matches and whose body actually validated against the declared schema (RichError.dataParsed === true). This keeps the narrowed type honest: if the server returns that status with a body that doesn't match the contract, the guard returns false instead of claiming error.data has a shape it doesn't.

Fail-open parsing

Error typing never masks the real error:

  • If no schema is declared for the status, RichError.data holds the raw JSON body and RichError.dataParsed is false.

  • If the body does not match the declared schema, RichError.data falls back to the raw JSON body and RichError.dataParsed is false.

  • Only when a schema is declared and the body passes it is RichError.dataParsed true and RichError.data the parsed, typed body.

  • All existing RichError fields (message, status, code, title, detail, errors) are unchanged.

Type helpers

ts
import type { InferError, InferErrors } from "@tahanabavi/typefetch";

type Conflict = InferError<typeof contracts.user.createUser, 409>;
// { code: "EMAIL_TAKEN"; conflictField: string }

type AllErrors = InferErrors<typeof contracts.user.createUser>;
// { 409: {...}; 422: {...} }

External tools (such as @tahanabavi/typefetch-nestjs) can iterate endpoint.errors to emit an OpenAPI responses[status] entry per declared error and validate error bodies against the contract.