
























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 toolchain is minimal:
fetch (Node 18+) or any HTTP clientInstall 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.
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.
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.
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.
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.
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
}
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 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:
openapi-typescript on every build.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.
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.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。