Generated Clients

Turn an OpenAPI document into a typed HTTP client, and know what breaks when the spec drifts.

Syntaxnpx openapi-typescript http://localhost:3000/docs-json -o src/api/schema.d.ts

Once 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

Example · typescript
// 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.

Example · typescript
// 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.

Example · bash
# 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.ts

Pinning 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.