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:
- Customize freely — Interceptors, auth, error normalization, custom methods
- Stay in sync — Regenerate types without diffing your way through merge hell
- 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
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:
| File | Purpose |
|---|---|
_internal/.api-cache.json | Spec hash + timestamp — "did anything change?" |
_internal/openapi.json | Cached OpenAPI spec (always JSON, regardless of source format) |
_generated/api.types.ts | Raw paths/components/operations from openapi-typescript |
_generated/api.operations.ts | Typed api.op.<operationId>() methods |
_generated/api.contracts.ts | Named 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:
| File | Purpose |
|---|---|
api.client.ts | Main typed client — path methods + api.op |
api.instance.ts | Axios instance, base URL, interceptors |
api.error.ts | ApiError, Result<T>, safeRequest |
api.helpers.ts | Type 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.
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.
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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 --forceThis overwrites your customizations. Back up first, or accept the consequences like an adult.