Error Handling
Results don't throw. Your query layer can — on purpose.
chowbea-axios returns { data, error } instead of yeeting exceptions across your call stack. That is not a personality quirk. It’s so TypeScript can force you to look at the failure branch before you touch data.name.
The Result type
type Result<T> =
| { data: T; error: null }
| { data: null; error: ApiError };Every api.get / api.op.* call resolves to this. No silent success. No mystery undefined.
Handle it at the call site
import { api } from "@/services/api/api.client";
const { data, error } = await api.op.getUser({ id: "123" });
if (error) {
console.error(error.message, error.code);
return;
}
// data is narrowed — celebrate responsibly
console.log(data.email);Prefer unwrapping at the query boundary
In a real app (TanStack Query, route loaders, whatever), don’t sprinkle if (error) in every component. Unwrap once in a query class and throw something that still carries status/code:
import type { ApiError, Result } from "@/services/api/api.error";
import { api } from "@/services/api/api.client";
export class QueryError extends Error {
readonly code: string;
readonly status: number | null;
constructor(apiError: ApiError) {
super(apiError.message);
this.name = "QueryError";
this.code = apiError.code;
this.status = apiError.status;
}
}
class BaseQuery {
protected readonly op = api.op;
protected async exec<T>(promise: Promise<Result<T>>): Promise<T> {
const { data, error } = await promise;
if (error) throw new QueryError(error);
return data;
}
}Then domain methods stay one-liners:
readonly getUser = (id: string) => this.exec(this.op.getUser({ id }));TanStack Query gets a thrown error (what it expects). Global handlers still see code / status. Everyone wins except the old try/catch spaghetti.
See The Query Layer for the full stack.
ApiError shape
interface ApiError {
message: string;
code: string;
status: number | null;
request: RequestContext;
details?: unknown;
}| Code | Typical status | Meaning |
|---|---|---|
NETWORK_ERROR | null | The network ghosted you |
TIMEOUT | null | Patience expired |
BAD_REQUEST | 400 | You sent nonsense |
UNAUTHORIZED | 401 | Login harder |
FORBIDDEN | 403 | You shall not pass |
NOT_FOUND | 404 | Gone (or never was) |
CONFLICT | 409 | Two truths entered, one left |
VALIDATION_ERROR | 422 | Field-level shame |
RATE_LIMITED | 429 | Chill |
SERVER_ERROR | 5xx | Not your fault (this time) |
REQUEST_ERROR | other | Catch-all client error |
UNKNOWN_ERROR | — | The void |
Branch on codes
if (error) {
switch (error.code) {
case "NOT_FOUND":
return null;
case "UNAUTHORIZED":
window.location.href = "/login";
return;
default:
throw new QueryError(error);
}
}Type guards
import { isSuccess, isError } from "@/services/api/api.client";
const result = await api.op.getUser({ id: "123" });
if (isSuccess(result)) console.log(result.data.email);
if (isError(result)) console.error(result.error.code);Normalization
Backends invent error shapes the way startups invent job titles. The client normalizes common patterns (FastAPI detail, ASP.NET Problem Details, { message }, { errors: [...] }, etc.) into error.message. Raw body still lives on error.details when you need the crime scene.
Escape hatch: safeRequest + raw Axios
Got an endpoint missing from the OpenAPI spec? Don’t pretend it exists on api.op. Use the underlying instance:
import { axiosInstance } from "@/services/api/api.instance";
import { safeRequest } from "@/services/api/api.error";
const { data, error } = await safeRequest(
axiosInstance.post("/undocumented/legacy", body),
);That’s the adult way to admit the spec is incomplete.