Skip to content

Types and fetch client

@codewithagents/openapi-gen reads your OpenAPI 3.x spec and writes a fully typed native fetch client, TypeScript interfaces, and optional Zod schemas into your project. Every generated file passes prettier --check and TypeScript strict: true out of the box, with zero runtime dependencies beyond fetch. The package is tested against 128 real-world specs (Stripe, GitHub, Spotify, OpenAI, and more) on every PR.

Running the generator produces up to five files in your output directory:

FileContents
models.tsTypeScript interfaces and union types for every schema in components.schemas
client.tsOne typed async function per API operation, using native fetch
client-config.tsconfigureClient() and getConfig(): set base URL, auth, and headers once at startup
index.tsBarrel re-export of all three files above
server.tscreateServerClient() factory (generated only when server_client: true)

When input_schema is set, the generator also bootstraps a schemas.ts file at the path you specify on the first run.

Key guarantees:

  • Zero runtime footprint. The generated client uses only fetch. No axios, no wrapper libraries.
  • Prettier-clean. Every file passes prettier --check with your project’s own Prettier config.
  • strict: true. All output compiles cleanly with TypeScript strict mode.
  • SSR-safe. Every generated function accepts an optional per-request config override. No global singleton mutation, safe for concurrent server requests.
  • OpenAPI 3.1.x is the primary target (including 3.1.1). OpenAPI 3.0.x is best-effort. Full support for $ref, allOf, anyOf, oneOf, and nullable.
Terminal window
npm i -D @codewithagents/openapi-gen

1. Create openapi-gen.config.json in your project root:

{
"input_openapi": "./openapi.json",
"output": "./src/api"
}

2. Run the generator:

Terminal window
npx openapi-gen

3. Import from the generated barrel:

import { configureClient } from './src/api'
import { listTasks, createTask, ApiError } from './src/api'
// Call once at app startup
configureClient({
baseUrl: 'https://api.example.com',
token: () => getAccessToken(),
})
// Every function is fully typed
const page = await listTasks({ status: 'pending', page: 1 })
try {
const task = await createTask({ title: 'Ship it' })
console.log(task.id)
} catch (err) {
if (err instanceof ApiError) {
console.error(err.status, err.body)
}
}

The config file must be valid JSON and must have a .json extension. By default the generator looks for openapi-gen.config.json in the current working directory.

FieldTypeRequiredDefaultDescription
input_openapistringYesnonePath to your OpenAPI spec (JSON or YAML)
outputstringYesnoneDirectory to write the generated files
input_schemastringNononePath to a user-owned Zod schema file. Bootstrapped on first run if the file does not exist; never overwritten after that
baseUrlstringNo""Default base URL embedded in the generated client-config.ts
server_clientbooleanNofalseWhen true, also generates server.ts with a createServerClient() factory

Full config example:

{
"input_openapi": "./openapi.json",
"output": "./src/api",
"input_schema": "./src/api/schemas.ts",
"baseUrl": "https://api.example.com",
"server_client": true
}
Terminal window
npx openapi-gen # uses openapi-gen.config.json in CWD
npx openapi-gen --config ./config/openapi-gen.config.json # explicit path
FlagArgumentDescription
--config<path-to.json>Path to a config file. Must end in .json. Relative paths inside the config file resolve from the config file’s directory, not from the shell’s working directory.

The generated ClientConfig interface covers auth, credentials, and global error handling:

// src/api/client-config.ts (auto-generated)
export interface ClientConfig {
baseUrl: string;
token?: string | (() => string | Promise<string>);
credentials?: RequestCredentials;
headers?: Record<string, string>;
onError?: (err: Error) => void;
}
export function configureClient(config: ClientConfig): void { ... }
export function getConfig(): Readonly<ClientConfig> { ... }

The default credentials is 'same-origin'. When the spec declares a cookie-based apiKey security scheme, the generated default is 'include' instead.

Call configureClient once at startup. Calling it a second time merges into the existing config rather than replacing it: fields not provided in the later call are inherited from the earlier call, not reset to defaults. The token field accepts a string or an async function, called per request, so JWT refresh flows work automatically.

Each schema in components.schemas becomes a TypeScript interface or type alias. String enums also produce a const array for runtime iteration:

// src/api/models.ts (auto-generated)
export type TaskStatus = 'pending' | 'in_progress' | 'done'
export const TaskStatusValues = ['pending', 'in_progress', 'done'] as const
export interface Task {
id: string
title: string
description?: string | null
status: TaskStatus
priority?: number
assigneeEmail?: string | null
createdAt: string /* date-time */
}
export interface CreateTaskRequest {
title: string
description?: string | null
priority?: number
assigneeEmail?: string | null
}

One typed async function per operation. Query parameters, path parameters, and request bodies are all typed. Each function accepts an optional config override as its final parameter:

// src/api/client.ts (auto-generated)
export async function listTasks(
params?: {
status?: "pending" | "in_progress" | "done";
page?: number;
pageSize?: number;
},
config?: Partial<ClientConfig>,
): Promise<TaskPage> { ... }
export async function createTask(
body: CreateTaskRequest,
config?: Partial<ClientConfig>,
): Promise<Task> { ... }

Every generated function throws ApiError for non-2xx responses. The class is emitted directly into client.ts:

// src/api/client.ts (auto-generated)
export class ApiError extends Error {
constructor(
public readonly status: number, // HTTP status code, e.g. 422
public readonly body: unknown // parsed JSON body, or null if parsing failed
) {
super(`API error ${status}`)
this.name = 'ApiError'
}
}

Use instanceof to narrow and read err.status and err.body:

try {
const task = await createTask({ title: 'Ship it' })
} catch (err) {
if (err instanceof ApiError) {
console.error(err.status, err.body)
// err.body is unknown — narrow it before use:
if (typeof err.body === 'object' && err.body !== null && 'message' in err.body) {
console.error((err.body as { message: string }).message)
}
}
}

ApiError is also the direct input to @codewithagents/api-errors, which maps validation errors to form field errors.

import { configureClient } from './src/api'
configureClient({
baseUrl: 'https://api.example.com',
token: () => getAccessToken(), // sync or async, called per request
credentials: 'omit',
})
configureClient({
baseUrl: 'https://api.example.com',
credentials: 'include', // sends HttpOnly cookies automatically
})
configureClient({
baseUrl: 'https://api.example.com',
onError: (err) => {
// called before every ApiError is thrown — use for logging or monitoring
Sentry.captureException(err)
},
})

Every generated function merges a config override into the global config for that single call. No singleton mutation, safe for concurrent server requests:

// app/tasks/page.tsx (Next.js Server Component)
import { listTasks } from '@/api/client';
import { getServerSession } from 'next-auth';
export default async function TasksPage() {
const session = await getServerSession();
const page = await listTasks(
{ status: 'pending' },
{
baseUrl: process.env.API_URL, // absolute URL required on server
token: session.accessToken,
credentials: 'omit',
},
);
return <TaskList tasks={page.items} />;
}

Server client factory (server_client: true)

Section titled “Server client factory (server_client: true)”

When server_client: true is set in config, the generator also writes server.ts. It exports createServerClient(), which pre-binds a per-request ClientConfig to every function so you only pass auth headers once per request:

// src/api/server.ts (auto-generated)
export function createServerClient(config: Partial<ClientConfig>) {
return {
listTasks: (params?: Parameters<typeof listTasks>[0]) => listTasks(params, config),
createTask: (body: Parameters<typeof createTask>[0]) => createTask(body, config),
getTask: (id: Parameters<typeof getTask>[0]) => getTask(id, config),
// ... all operations
}
}

Usage in a Next.js App Router page:

app/tasks/page.tsx
import { createServerClient } from '@/api/server';
export default async function TasksPage() {
const session = await getServerSession();
const api = createServerClient({
baseUrl: process.env.API_URL,
token: session.accessToken,
});
const page = await api.listTasks({ status: 'pending' });
const task = await api.getTask('featured-id');
return <TaskList tasks={page.items} featured={task} />;
}

Point input_schema at a file path and the generator bootstraps a Zod schema file on first run. The file is never overwritten. Re-running the generator updates models.ts to use z.infer<> types and adds runtime validation to client.ts.

Step 1: add input_schema to config

{
"input_openapi": "./openapi.json",
"output": "./src/api",
"input_schema": "./src/api/schemas.ts"
}

Step 2: run the generator once to bootstrap schemas.ts. Then edit it freely:

// src/api/schemas.ts (bootstrapped, then yours)
import { z } from 'zod'
export const TaskSchema = z
.object({
id: z.string(),
title: z.string(),
status: z.enum(['pending', 'in_progress', 'done']),
})
.passthrough() // forward-compatible: unknown server fields are preserved
export const CreateTaskRequestSchema = z.object({
title: z.string().min(1, 'Title is required'),
})

Step 3: re-run the generator. models.ts switches to z.infer<> types, client.ts adds parse calls:

// models.ts (regenerated with Zod integration)
import type { z } from 'zod'
import type { TaskSchema, CreateTaskRequestSchema } from './schemas.js'
export type Task = z.infer<typeof TaskSchema>
export type CreateTaskRequest = z.infer<typeof CreateTaskRequestSchema>
// client.ts (regenerated with Zod integration)
export async function createTask(
body: CreateTaskRequest,
config?: Partial<ClientConfig>
): Promise<Task> {
CreateTaskRequestSchema.parse(body) // validates request body before sending
const res = await _request('POST', '/api/v1/tasks', { body }, config)
return TaskSchema.parse(await res.json()) // throws ZodError on bad response
}

input_schema is bootstrapped once and never overwritten

Section titled “input_schema is bootstrapped once and never overwritten”

The first time you run the generator with input_schema set, the file is created at the path you specified. Every subsequent run skips that write entirely. Delete the file and re-run to get a fresh bootstrap from the current spec.

The drift warning printed to stderr mentions “Run with —reset-schema to re-bootstrap.” That flag does not exist in the CLI. The only way to reset the schema file is to delete it manually and re-run the generator.

The bootstrapped schemas.ts uses z.record(z.string(), ...) (two-argument form) and z.lazy() for circular references. Both require Zod v4. Zod v3 does not support the two-argument z.record signature.

Terminal window
pnpm add zod@^4

Request body validation uses .parse(), not .strip().parse()

Section titled “Request body validation uses .parse(), not .strip().parse()”

The generated client calls Schema.parse(body) before sending, not .strip().parse(). This is intentional: not all generated schemas are ZodObject (for example allOf maps to ZodIntersection and oneOf maps to ZodUnion, neither of which has .strip()). Use a wrapping schema to strip UI-only fields:

schemas.ts
export const CreateTaskRequestSchema = z.object({
title: z.string().min(1),
})
// For multi-step forms: extend with UI-only fields
export const CreateTaskFormSchema = CreateTaskRequestSchema.extend({
currentStep: z.number(),
termsAccepted: z.boolean(),
})
// Use CreateTaskFormSchema for React Hook Form.
// Strip UI fields before calling the API client:
const apiBody = CreateTaskRequestSchema.parse(formValues)
await createTask(apiBody)

The generator rejects output and input_openapi paths that resolve to system directories (/etc, /usr, /bin, /sys, /proc, /dev, C:\Windows, and similar). This is a security check that prevents accidental misconfiguration.

RequirementMinimum
Node.js18 (native fetch is required at runtime; the generator itself uses fs/promises and path)
TypeScript5.x recommended; output targets strict: true
Module formatESM only. The generated files use .js extensions in all imports (NodeNext resolution)
PrettierAny version in your project — the generator reads your config to format output
Zod (optional)v4 required when input_schema is used

The package itself is published as ESM. It must be used from an ESM context ("type": "module" in package.json or a .mjs file).

The generator looks for openapi-gen.config.json in the current working directory. If you run the CLI from a subdirectory or use a different file name, pass --config <path> explicitly. The path argument must end in .json.

The generator always overwrites models.ts, client.ts, client-config.ts, index.ts, and (if enabled) server.ts on every run. However, schemas.ts (the input_schema file) is never overwritten after the first bootstrap. If you want a fresh Zod schema file, delete it and re-run.

Ensure your config includes input_schema pointing at an existing schemas.ts file. On first run with input_schema set but no file present, the generator bootstraps the schema file only; run the generator a second time to get the Zod-enhanced models.ts and client.ts.

React Query hooks

Add @codewithagents/openapi-react-query to generate typed useQuery and useMutation hooks on top of the client this package produces.

Learn more

Server interface

Add @codewithagents/openapi-server to generate a typed service interface and optional Hono router from the same spec.

Learn more

Form error mapping

Add @codewithagents/api-errors to map ApiError responses from the generated client directly to form field errors.

Learn more