chowbea-axios
Usage

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:

queries/base.query.ts
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;
}
CodeTypical statusMeaning
NETWORK_ERRORnullThe network ghosted you
TIMEOUTnullPatience expired
BAD_REQUEST400You sent nonsense
UNAUTHORIZED401Login harder
FORBIDDEN403You shall not pass
NOT_FOUND404Gone (or never was)
CONFLICT409Two truths entered, one left
VALIDATION_ERROR422Field-level shame
RATE_LIMITED429Chill
SERVER_ERROR5xxNot your fault (this time)
REQUEST_ERRORotherCatch-all client error
UNKNOWN_ERRORThe 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.

Next Steps

On this page