Generated Clients
Turn an OpenAPI document into a typed HTTP client, and know what breaks when the spec drifts.
npx openapi-typescript http://localhost:3000/docs-json -o src/api/schema.d.tsOnce the spec exists, nobody needs to hand-write the calling code. A generator reads the document and emits types, or whole functions, for every operation. The caller then gets a compile error instead of a 404 when a path changes, which is the whole point of keeping the spec accurate.
Two shapes of generator
Type-only tools, such as openapi-typescript, emit a paths type and nothing else; a thin runtime like openapi-fetch uses it to type ordinary fetch calls. Full SDK generators, such as @openapitools/openapi-generator-cli, emit classes with one method per operation. The first keeps the bundle small and stays close to HTTP. The second is easier to hand to a team that does not want to think about paths at all.
Naming is the contract
Generators derive identifiers from the spec, so renames leak. A schema is named after its class unless @ApiSchema({ name }) says otherwise. An operation's method name comes from operationId, which defaults to ControllerClass_methodName, with a version suffix when versioning is enabled. Renaming OrdersController therefore renames every generated method for it. Pin both with @ApiSchema and an operationIdFactory before anyone depends on the output.
Drift
Nothing checks a generated client against the running server. A response reshaped by an interceptor, or trimmed by a serializer, will not match the declared type, and the compiler will believe the spec. Generate the document in CI and fail the build when the committed copy differs.
Example
// web/src/api/client.ts
import createClient from 'openapi-fetch';
// Generated file, never edited by hand:
// npx openapi-typescript ./openapi.json -o web/src/api/schema.d.ts
// It exports the `paths`, `components` and `operations` types.
import type { components, paths } from './schema';
export type Order = components['schemas']['Order'];
export const api = createClient<paths>({
baseUrl: process.env.API_URL ?? 'http://localhost:3000'
});
export async function getOrder(id: string): Promise<Order> {
// The path string is checked against the spec, and so are the params and
// the response. A typo in '/orders/{id}' does not compile.
const { data, error } = await api.GET('/orders/{id}', {
params: { path: { id } }
});
// `error` is typed from the documented failure responses, `data` from the
// documented 200. Exactly one of the two is set.
if (error) {
throw new Error(`getOrder failed: ${JSON.stringify(error)}`);
}
return data;
}
export async function createOrder(
body: paths['/orders']['post']['requestBody']['content']['application/json']
): Promise<Order> {
const { data, error } = await api.POST('/orders', { body });
if (error) {
throw new Error(`createOrder failed: ${JSON.stringify(error)}`);
}
return data;
}When to use it
- Give a front-end team a typed client so a renamed route is a compile error, not a runtime 404.
- Fail CI when a controller changes and the committed openapi.json was not regenerated.
- Hand a partner team an SDK instead of a documentation link and a base URL.
- Keep generated type names stable across an internal refactor of entity classes.
More examples
Write the spec to a file without starting a server
Run this with ts-node, or run the compiled output, to produce openapi.json as a build artefact, which is what both the generators and the CI diff consume. Creating the app does run every module's initialisation, so a module that connects to a database on startup will try to; point it at test configuration or the script fails in CI for reasons unrelated to the spec.
// scripts/generate-openapi.ts
import { writeFileSync } from 'node:fs';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from '../src/app.module';
async function generate() {
// The app is created but never listens: createDocument needs the
// initialised module graph, not an open port.
const app = await NestFactory.create(AppModule, { logger: false });
await app.init();
const config = new DocumentBuilder()
.setTitle('Orders API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
writeFileSync('openapi.json', JSON.stringify(document, null, 2) + '\n');
await app.close(); // otherwise the process hangs on open handles
}
generate();Generating and diffing in CI
The diff step is what makes the spec trustworthy: a pull request that changes a route but not openapi.json fails before review. Reach for Option A on a TypeScript front end that already uses fetch, and Option B when the consumer wants a method to call and no knowledge of the URL layout. The openapi-generator CLI needs a Java runtime, which is usually the deciding factor in a Node-only pipeline.
# Regenerate the committed spec and fail if it moved.
node dist/scripts/generate-openapi.js
git diff --exit-code openapi.json \
|| { echo 'openapi.json is stale: commit the regenerated file'; exit 1; }
# Option A: types only, paired with openapi-fetch at runtime.
npx openapi-typescript openapi.json -o web/src/api/schema.d.ts
# Option B: a full SDK, one class per tag and one method per operationId.
npx @openapitools/openapi-generator-cli generate \
-i openapi.json \
-g typescript-axios \
-o web/src/generated
# Generated output is build output: either commit it deliberately or
# regenerate it in CI, never half of each.
git diff --exit-code web/src/api/schema.d.tsPinning the names clients depend on
Generated identifiers are public API for whoever imports the client, and the defaults tie them to your internal class names. Set operationIdFactory and @ApiSchema once, early, while the only consumer is you. Doing it later means a release note telling every caller which import changed, and a review where half the diff is renames.
// scripts/generate-openapi.ts (excerpt)
import { SwaggerDocumentOptions, SwaggerModule } from '@nestjs/swagger';
const options: SwaggerDocumentOptions = {
// Default: `OrdersController_findAll`. Renaming the controller would rename
// every generated method. The handler name alone is more stable, but it
// must be unique across all controllers or operation ids collide.
operationIdFactory: (controllerKey, methodKey, version) =>
version ? `${methodKey}_v${version}` : methodKey
};
const document = SwaggerModule.createDocument(app, config, options);
// src/orders/entities/order.entity.ts (excerpt)
import { ApiProperty, ApiSchema } from '@nestjs/swagger';
// The component is named 'Order' whatever the class is called, so the
// generated type name survives a refactor of the entity.
@ApiSchema({ name: 'Order' })
export class OrderEntity {
@ApiProperty({ format: 'uuid' })
id: string;
}
Discussion