chowbea-axios
Advanced

Generated Files

Understanding the generated file structure and customization options.

init scaffolds the tree. fetch fills it. After that, you have three kinds of files: things the CLI owns, things you own, and a folder you should pretend doesn't exist until something breaks.

Understanding which is which is the difference between "I customized my client" and "why did my interceptor vanish."

Why This Structure?

The layout is deliberately boring — separation of concerns, not cleverness:

  • _internal/ — Cached spec and hash metadata. Gitignored. Never edit. The CLI's attic.
  • _generated/ — Types, operations, contracts. Overwritten on every fetch/generate. Your edits here have the lifespan of a mayfly.
  • Root-level files — Client, instance, error handling, helpers. Created once, never overwritten. This is where you live.

That split buys you three things:

  1. Customize freely — Interceptors, auth, error normalization, custom methods
  2. Stay in sync — Regenerate types without diffing your way through merge hell
  3. Debug sanely — Peek at the cached spec when "it worked yesterday" stops being true

For how app code should consume all of this, see The Query Layer.

File Structure Overview

.api-cache.json
openapi.json
api.types.ts
api.operations.ts
api.contracts.ts ← day-to-day type imports
api.client.ts # created once — your entry point
api.instance.ts
api.error.ts
api.helpers.ts

How Files Relate

┌──────────────────────────────────────────────────────────────────┐
│                        Your Application                          │
└──────────────────────────────────────────────────────────────────┘


                    ┌───────────────────────┐
                    │     api.client.ts     │  ◀── import { api }
                    │   (main entry point)  │
                    └───────────────────────┘
                          │           │
            ┌─────────────┘           └─────────────┐
            ▼                                       ▼
┌───────────────────────┐               ┌───────────────────────┐
│   api.instance.ts     │               │  _generated/          │
│   (axios config)      │               │  api.operations.ts    │
└───────────────────────┘               │  api.types.ts         │
            │                           │  api.contracts.ts     │
            ▼                           └───────────────────────┘
┌───────────────────────┐                          │
│    api.error.ts       │  ◀───────────────────────┘
│    api.helpers.ts     │
└───────────────────────┘

Day-to-day rule: types from api.contracts.ts, calls via api.op.*. Path-based api.get still works; operation-based is what scales. Details in operations and The Query Layer.

File Categories

Auto-Managed (Don't Edit)

Overwritten on every fetch or generate:

FilePurpose
_internal/.api-cache.jsonSpec hash + timestamp — "did anything change?"
_internal/openapi.jsonCached OpenAPI spec (always JSON, regardless of source format)
_generated/api.types.tsRaw paths/components/operations from openapi-typescript
_generated/api.operations.tsTyped api.op.<operationId>() methods
_generated/api.contracts.tsNamed interfaces per operation — cmd+click navigation, not string archaeology

Never edit _internal/ or _generated/. The next generation will politely erase your work.

_internal/ is gitignored by default. If it's missing locally, run fetch — the CLI recreates it.

Editable (Generated Once)

Created only if they don't already exist:

FilePurpose
api.client.tsMain typed client — path methods + api.op
api.instance.tsAxios instance, base URL, interceptors
api.error.tsApiError, Result<T>, safeRequest
api.helpers.tsType utilities when contracts aren't enough

Modify these freely. They survive regeneration.

Detailed File Descriptions

api.types.ts

Generated by openapi-typescript. The raw material — paths, components, operations. Useful when you're spelunking; rarely what you import in app code.

_generated/api.types.ts
export interface paths {
  "/users": {
    get: {
      parameters: { query?: { limit?: number } };
      responses: { 200: { content: { "application/json": User[] } } };
    };
    post: {
      requestBody: { content: { "application/json": CreateUserInput } };
      responses: { 201: { content: { "application/json": User } } };
    };
  };
  // ...
}

export interface components {
  schemas: {
    User: { id: string; name: string; email: string };
    CreateUserInput: { name: string; email: string };
    // ...
  };
}

api.contracts.ts

Start here for imports. Named interfaces per operation — UserDto, CreateUserBody, ListUsersQueryParams. Your IDE can navigate to them. ServerModel<"UserDto"> is an escape hatch, not a lifestyle.

_generated/api.contracts.ts
export interface UserDto {
  id: string;
  name: string;
  email: string;
}

export interface CreateUserBody {
  name: string;
  email: string;
}

export interface ListUsersQueryParams {
  limit?: number;
  offset?: number;
}

See Type Helpers when you genuinely need to dig into paths/components.

api.operations.ts

One method per operationId. No ID in the spec? No method. Fix the spec, not the generator.

_generated/api.operations.ts
export const createOperations = (apiClient: any) => ({
  /**
   * List all users
   * @operationId listUsers
   * @method GET
   * @path /users
   */
  listUsers: (config?: RequestConfig<"/users", "get">) =>
    apiClient.get("/users", config),

  /**
   * Get user by ID
   * @operationId getUserById
   * @method GET
   * @path /users/{id}
   */
  getUserById: (
    pathParams: { id: string | number },
    config?: RequestConfig<"/users/{id}", "get">
  ) => apiClient.get("/users/{id}", pathParams, config),

  // ...
});

api.client.ts

What you import in application code:

api.client.ts
import { axiosInstance } from "./api.instance";
import { safeRequest } from "./api.error";
import { createOperations } from "./_generated/api.operations";

const api = {
  get<P extends Paths>(...) { /* ... */ },
  post<P extends Paths>(...) { /* ... */ },
  put<P extends Paths>(...) { /* ... */ },
  delete<P extends Paths>(...) { /* ... */ },
  patch<P extends Paths>(...) { /* ... */ },

  get op() {
    return createOperations(this);
  },
};

export { api };

Preferred call site:

import { api } from "./api.client";

const { data, error } = await api.op.getUserById({ id: "123" });

api.instance.ts

Axios instance with an auth interceptor shaped by auth_mode in config:

auth_mode = "bearer-localstorage" — SPA pattern, reads token from localStorage:

api.instance.ts (auth_mode = bearer-localstorage)
import axios from "axios";

export const tokenKey = "auth-token";

export const axiosInstance = axios.create({
  baseURL: process.env.API_BASE_URL,
  withCredentials: true,
  timeout: 30000,
});

axiosInstance.interceptors.request.use((config) => {
  if (typeof window !== "undefined") {
    const raw = localStorage.getItem(tokenKey);
    if (raw) {
      try {
        // Handles plain strings, { token }, and Zustand-style { state: { token } }
        const parsed = JSON.parse(raw);
        const token = parsed.state?.token ?? parsed.token ?? parsed;
        if (typeof token === "string") {
          config.headers.Authorization = `Bearer ${token}`;
        }
      } catch {
        config.headers.Authorization = `Bearer ${raw}`;
      }
    }
  }
  return config;
});

auth_mode = "custom" — default, ships with a TODO for you to fill in:

api.instance.ts (auth_mode = custom)
import axios from "axios";

export const axiosInstance = axios.create({
  baseURL: process.env.API_BASE_URL,
  withCredentials: true,
  timeout: 30000,
});

axiosInstance.interceptors.request.use((config) => {
  // TODO: implement your auth — e.g. read from a store, cookie, or session
  return config;
});

auth_mode = "none" — no interceptor. Public APIs, or auth handled upstream.

env_accessor controls whether baseURL uses process.env.X or import.meta.env.X. Full breakdown: authentication.

api.error.ts

Every call returns Result<T>{ data, error }, no throws from the client itself:

api.error.ts
export interface ApiError {
  message: string;
  code: string;
  status: number | null;
  request: RequestContext;
  details?: unknown;
}

export type Result<T> =
  | { data: T; error: null }
  | { data: null; error: ApiError };

export async function safeRequest<T>(
  promise: Promise<AxiosResponse<T>>
): Promise<Result<T>> {
  try {
    const response = await promise;
    return { data: response.data, error: null };
  } catch (err) {
    return { data: null, error: createApiError(err) };
  }
}

App code unwraps once in a query class; components shouldn't negotiate with Result directly. See Error Handling and The Query Layer.

api.helpers.ts

Type utilities for when contracts don't cover your case:

api.helpers.ts
export type ApiRequestBody<P extends Paths, M extends HttpMethod> = ...;
export type ApiResponseData<P extends Paths, M extends HttpMethod> = ...;
export type ServerModel<ModelName extends keyof components["schemas"]> = ...;
// ...

Reach for these when you're doing generic utilities. For DTOs in feature code, use api.contracts.ts.

Customizing Editable Files

Custom Interceptors

Response logging, token refresh, whatever your backend team forgot to document — api.instance.ts:

api.instance.ts
axiosInstance.interceptors.response.use(
  (response) => {
    console.log(`[API] ${response.config.method} ${response.config.url}`, response.status);
    return response;
  },
  (error) => {
    console.error(`[API Error]`, error.response?.status, error.message);
    return Promise.reject(error);
  }
);

Custom Token Handling

Your auth store isn't localStorage? Shockingly common:

api.instance.ts
axiosInstance.interceptors.request.use((config) => {
  const session = sessionStorage.getItem("session");
  if (session) {
    const { accessToken } = JSON.parse(session);
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

Custom Error Normalization

Your API returns { errorMessage: "..." } instead of anything sensible — extend api.error.ts:

api.error.ts
export function normalizeErrorMessage(error: unknown): string {
  if (error && typeof error === "object") {
    const e = error as Record<string, unknown>;

    if (e.errorMessage && typeof e.errorMessage === "string") {
      return e.errorMessage;
    }
  }

  // Fall back to default handling
  // ... existing code ...
}

Adding Custom Methods

Batch helpers, health checks, whatever doesn't belong in the spec — api.client.ts:

api.client.ts
const api = {
  // ... existing methods ...

  async batch<T>(requests: Promise<Result<T>>[]): Promise<Result<T>[]> {
    return Promise.all(requests);
  },

  async healthCheck() {
    return this.get("/health");
  },
};

Regenerating Editable Files

Want the defaults back? You asked for it:

chowbea-axios init --force

This overwrites your customizations. Back up first, or accept the consequences like an adult.

Next Steps

On this page