Generate Type-Safe API Clients From Your OpenAPI Spec
Stop hand-writing fetch wrappers that drift from the spec. Here is how to generate fully typed API clients from OpenAPI specs, catch breaking changes in CI, and never debug a "but the API returned field X" production issue again.
The code path that costs the most cumulative time in a mid-size API project is not the tricky business logic. It is the gap between your OpenAPI spec and the TypeScript types you wrote by hand on the client.
You define a PATCH /users/:id endpoint in the spec with a status field that accepts 'active' | 'suspended' | 'archived'. The client team (which might be you, three months later) types status: string in the fetch wrapper and moves on. No one notices until production shows a user marked 'deleted' that the server silently ignores, or until a refactor renames the field to accountStatus in the spec but not the client. The integration test suite, which mocks the API instead of validating against the spec, passes green.
This is the contract drift problem. Every hand-typed API client is a liability that grows with every endpoint you add. The fix is to stop typing API responses by hand and generate them from the spec.
The stack
The toolchain is minimal:
- openapi-typescript reads an OpenAPI 3.0 or 3.1 spec and emits TypeScript types
- TypeScript’s native
fetch(Node 18+) or any HTTP client - A thin runtime wrapper that turns the generated types into callable functions
Install it:
npm i -D openapi-typescript
Then generate types from a local spec file or a remote URL:
npx openapi-typescript ./specs/api.yaml -o ./src/lib/api-types.ts
That single command produces a file with types for every path, method, request body, query parameter, and response. The file is around 2,000 lines for a 40-endpoint API and generates in under a second. You never edit it by hand.
The generated types
Given an OpenAPI 3.1 spec that looks like this:
openapi: '3.1.0'
info:
title: User Service
version: 1.0.0
paths:
/users:
get:
operationId: listUsers
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: A paginated list of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
total:
type: integer
page:
type: integer
/users/{id}:
patch:
operationId: updateUser
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateUserPayload'
responses:
'200':
description: Updated user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
components:
schemas:
User:
type: object
required:
- id
- email
- status
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
nullable: true
status:
type: string
enum: [active, suspended, archived]
createdAt:
type: string
format: date-time
UpdateUserPayload:
type: object
properties:
name:
type: string
status:
type: string
enum: [active, suspended, archived]
Running openapi-typescript produces types that look like this:
// Generated. Do not edit.
export interface paths {
'/users': {
get: {
parameters: {
query?: {
page?: number;
limit?: number;
};
};
responses: {
200: {
content: {
'application/json': {
data: components['schemas']['User'][];
total: number;
page: number;
};
};
};
};
};
};
'/users/{id}': {
patch: {
parameters: {
path: {
id: string;
};
};
requestBody: {
content: {
'application/json': components['schemas']['UpdateUserPayload'];
};
};
responses: {
200: {
content: {
'application/json': components['schemas']['User'];
};
};
404: {
content: {
'application/json': {
error: string;
};
};
};
};
};
};
}
export interface components {
schemas: {
User: {
id: string;
email: string;
name: string | null;
status: 'active' | 'suspended' | 'archived';
createdAt: string;
};
UpdateUserPayload: {
name?: string;
status?: 'active' | 'suspended' | 'archived';
};
};
}
The enum values, the nullable: true on name, the format: uuid that becomes string (with a semantic format annotation), the required array that determines which properties are optional — every detail from the spec is reflected in the types.
A typed fetch client
Generated types alone do not do HTTP. You need a thin wrapper that maps paths and methods to fetch calls and validates that your code uses them correctly.
// src/lib/api-client.ts
import type { paths, components } from './api-types';
type PathKeys = keyof paths;
type Method<Path extends PathKeys> = keyof paths[Path];
type ResponseContent<
Path extends PathKeys,
M extends Method<Path>,
Status extends keyof paths[Path][M]['responses']
> = paths[Path][M]['responses'][Status] extends {
content: { 'application/json': infer T };
}
? T
: never;
export class ApiClient {
private baseUrl: string;
private headers: Record<string, string>;
constructor(baseUrl: string, token?: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.headers = {
'Content-Type': 'application/json',
};
if (token) {
this.headers['Authorization'] = `Bearer ${token}`;
}
}
async get<Path extends PathKeys>(
path: Path,
init?: { query?: paths[Path]['get']['parameters']['query']; signal?: AbortSignal }
): Promise<ResponseContent<Path, 'get', 200>> {
const url = new URL(`${this.baseUrl}${path}`);
if (init?.query) {
for (const [key, value] of Object.entries(init.query)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
const res = await fetch(url, {
method: 'GET',
headers: this.headers,
signal: init?.signal,
});
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json();
}
async patch<Path extends PathKeys>(
path: Path,
body: paths[Path]['patch']['requestBody']['content']['application/json'],
init?: { signal?: AbortSignal }
): Promise<ResponseContent<Path, 'patch', 200>> {
const url = new URL(`${this.baseUrl}${path}`);
const res = await fetch(url, {
method: 'PATCH',
headers: this.headers,
body: JSON.stringify(body),
signal: init?.signal,
});
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json();
}
}
export class ApiError extends Error {
constructor(public status: number, body: string) {
super(`API ${status}: ${body.slice(0, 200)}`);
this.name = 'ApiError';
}
}
export type User = components['schemas']['User'];
export type UpdateUserPayload = components['schemas']['UpdateUserPayload'];
Now the calling code looks like this:
import { ApiClient, type User } from './lib/api-client';
const api = new ApiClient('https://api.example.com', process.env.API_TOKEN);
// Fully typed response
const users = await api.get('/users', {
query: { page: 1, limit: 20 },
});
// ^? { data: User[]; total: number; page: number }
// This does not compile:
await api.get('/users', { query: { page: 'one' } });
// ~~~~~~~~ Type 'string' is not assignable to type 'number'
// This does not compile:
await api.get('/users', { query: { sort: 'name' } });
// ~~~~~~~~~~~~~~~~~ Object literal may only specify known properties
The compiler catches wrong query types, missing required path params, and invalid field values before the request ever leaves your machine.
Error handling with discriminated responses
The simple client above throws on any non-2xx. But the generated types know exactly which response shapes the spec defines for each status code. You can build a client that returns a discriminated union:
type ApiResponse<Data, Error> =
| { ok: true; data: Data }
| { ok: false; error: Error; status: number };
async function safePatch<Path extends PathKeys>(
path: Path,
body: paths[Path]['patch']['requestBody']['content']['application/json'],
): Promise<
ApiResponse<
paths[Path]['patch']['responses'][200]['content']['application/json'],
paths[Path]['patch']['responses'][404]['content']['application/json']
>
> {
try {
const data = await api.patch(path, body);
return { ok: true, data };
} catch (err) {
if (err instanceof ApiError) {
// The 404 response shape is known at compile time
return {
ok: false,
error: { error: 'User not found' },
status: err.status,
};
}
throw err;
}
}
Now every consumer must handle both branches. No forgotten error paths.
CI gate: catch breaking changes at build time
The biggest win is not in the editor. It is in CI. Add a step that regenerates the types and fails the build if anything changed compared to the committed version:
# .github/workflows/ci.yml
- name: Generate API types
run: npx openapi-typescript ./specs/api.yaml -o ./src/lib/api-types.ts
- name: Check for uncommitted changes
run: |
if ! git diff --exit-code ./src/lib/api-types.ts; then
echo "ERROR: API types are out of date. Run the generate command and commit the result."
exit 1
fi
If someone updates the spec but does not regenerate the types, or if the backend team deploys a spec change that breaks the contract, CI fails with a clear message. You catch the drift before it reaches production.
For extra safety, run TypeScript’s tsc --noEmit against the client code. If the spec removed a field that your code uses, the type error tells you exactly which file and line to fix.
Pagination and shared request context
Real APIs need more than simple GET and PATCH wrappers. Here is how to handle paginated endpoints with proper typing:
async function* paginate<T>(
path: string,
getPage: (params: { page: number }) => Promise<{ data: T[]; total: number }>,
options?: { pageSize?: number; maxPages?: number }
): AsyncGenerator<T, void, undefined> {
const pageSize = options?.pageSize ?? 100;
const maxPages = options?.maxPages ?? Infinity;
let page = 1;
let fetched = 0;
while (page <= maxPages) {
const { data, total } = await getPage({ page });
for (const item of data) {
yield item;
fetched++;
}
if (fetched >= total) break;
page++;
}
}
// Usage with the typed client
const api = new ApiClient(API_BASE_URL, TOKEN);
for await (const user of paginate('/users', (params) =>
api.get('/users', { query: { ...params, limit: 100 } })
)) {
console.log(user.email);
// ^? string
}
And for endpoints that need auth headers from a rotating token, extend the client with a token refresh interceptor:
export class AuthApiClient extends ApiClient {
private refreshToken: string;
private expiresAt: number;
constructor(baseUrl: string, initialToken: string, refreshToken: string) {
super(baseUrl, initialToken);
this.refreshToken = refreshToken;
this.expiresAt = Date.now() + 15 * 60 * 1000; // 15 min
}
protected async ensureToken(): Promise<void> {
if (Date.now() < this.expiresAt) return;
// Calls the /auth/refresh endpoint (also typed from the spec)
const { token, expiresIn } = await super.post('/auth/refresh', {
refreshToken: this.refreshToken,
});
this.setToken(token);
this.expiresAt = Date.now() + expiresIn * 1000;
}
// Override fetch methods to call ensureToken() first
}
When not to do this
Generated clients are not always the right call.
Your API changes faster than your build cycle. If endpoints are experimental and the spec is always out of date, generation adds friction without value. Freeze the spec first, then generate.
You ship a public SDK. A generated client with nested generics makes a poor developer experience. Users of your SDK do not want type gymnastics. Hand-write a clean facade over the generated types.
The spec is downstream of the implementation. If you write the API code first and generate the OpenAPI spec from it (e.g. with tsoa or express-zod-api), then generating a client from the spec is tautological. You are back to the same codebase. Use TypeScript project references or a shared types package instead.
You need streaming or binary responses. The generated types only describe JSON request/response bodies. Streaming endpoints, file uploads, and Server-Sent Events need separate handling.
The practical takeaway
The threshold for adopting this pattern is one API client with more than three endpoints. Beyond that, the maintenance cost of hand-typed fetch wrappers exceeds the setup cost of openapi-typescript.
The workflow is:
- Keep your OpenAPI spec in the same repo (or a shared submodule).
- Generate types with
openapi-typescripton every build. - Write a thin typed client wrapper (30-60 lines).
- Add a CI check that fails if the generated types drift from the spec.
- TypeScript tells you exactly where your code broke when the spec changes.
No more “the API returned a field I did not expect” bugs. No more manual typing of 200-line response bodies. No more wondering whether the spec or the client is the source of truth.
A note from Yojji
The pattern in this post — treating your API contract as a source of truth that generates both server validation and client types — is the kind of engineering discipline that prevents entire categories of production bugs before they happen. It is not complicated, but it requires the experience to know where to invest the setup cost.
Yojji is an international custom software development company that builds products for teams who want that level of rigor without building the expertise internally. Founded in 2016 with offices across Europe, the US, and the UK, Yojji runs dedicated engineering teams specializing in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and full-cycle product development. If your API clients are outgrowing hand-typed wrappers, they have the pattern book ready.