Error Handling
TypeFetch normalizes errors into RichError.
client.onError((error) => {
console.error(error.message);
console.error(error.status);
console.error(error.code);
});RichError may include:
{
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: 2called it four times). If you were compensating for the duplicates, remove that.
Example:
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
datatyped against the endpoint'serrorsmap.Anything else → the raw text lands in
error.detail.Empty body → still a
RichErrorcarryingstatus.
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 rawSyntaxErrorand the status never reached the caller. If you have acatchthat special-cases thatSyntaxError, it now receives aRichErrorinstead.
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.
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.
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.dataholds the raw JSON body andRichError.dataParsedisfalse.If the body does not match the declared schema,
RichError.datafalls back to the raw JSON body andRichError.dataParsedisfalse.Only when a schema is declared and the body passes it is
RichError.dataParsedtrueandRichError.datathe parsed, typed body.All existing
RichErrorfields (message,status,code,title,detail,errors) are unchanged.
Type helpers
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.