How it works: how @nestjs/swagger builds an OpenAPI document

@nestjs/swagger does not parse your TypeScript source. It builds the OpenAPI document at application startup by inspecting the running Nest application. That one fact explains most of its behavior and most of its gaps.

From decorators to JSON

The flow has four stages:

  1. Decoration. Route decorators such as @Get(), @Body() and @Param() record routing metadata on controller methods. Swagger decorators such as @ApiProperty(), @ApiResponse() and @ApiTags() add documentation metadata. When emitDecoratorMetadata is enabled, the TypeScript compiler also emits design:type and design:paramtypes entries for decorated members.
  2. Configuration. DocumentBuilder assembles the document-level fields: title, version, servers and security schemes.
  3. Scanning. SwaggerModule.createDocument(app, config) walks every registered module and controller. It reads the stored metadata and converts each route into a path item and each referenced DTO class into a schema under components.schemas.
  4. Serving. SwaggerModule.setup() exposes the resulting object as JSON and serves Swagger UI on top of it. You can also serialize the same object to a file.

Why DTOs must be classes

Reflection only sees what exists at runtime. Interfaces and type aliases are erased during compilation, so a @Body() typed as an interface leaves the scanner with nothing to describe. A class remains a real constructor that carries metadata, so request and response shapes must be classes.

Who this serves

The model works best for backend and platform teams whose specs feed generated clients and SDKs. In that setup, every missing or wrong schema becomes a type error or a runtime surprise in a consumer's codebase. The rest of this article covers how to close those gaps.

import { Body, Controller, Module, Post } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ApiCreatedResponse, ApiProperty, ApiTags, DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';

// A class, not an interface: it exists at runtime, so the scanner can read its metadata.
export class CreateUserDto {
  @ApiProperty({ example: 'ada@example.com' })
  email: string;

  @ApiProperty({ required: false, example: 'Ada' })
  displayName?: string;
}

@ApiTags('users') // groups this controller's routes in the spec and in Swagger UI
@Controller('users')
export class UsersController {
  @Post()
  @ApiCreatedResponse({ description: 'User created' })
  create(@Body() body: CreateUserDto) {
    // design:paramtypes tells the scanner that the body is CreateUserDto
    return body;
  }
}

@Module({ controllers: [UsersController] })
class AppModule {}

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // Document-level fields only; paths and schemas come from scanning.
  const config = new DocumentBuilder().setTitle('Users API').setVersion('1.0').addBearerAuth().build();
  // Walks registered controllers and DTOs and builds the OpenAPI object.
  const document = SwaggerModule.createDocument(app, config);
  writeFileSync('./openapi.json', JSON.stringify(document, null, 2)); // input for client generators
  SwaggerModule.setup('docs', app, document); // Swagger UI at /docs
  await app.listen(3000);
}
bootstrap();

A minimal Nest app where a decorated DTO class and controller are scanned at startup by SwaggerModule.createDocument, then written to openapi.json and served through Swagger UI.

Why reflection alone produces incomplete schemas, and what the CLI plugin fixes

TypeScript types disappear at compile time. With emitDecoratorMetadata enabled, the compiler records only a runtime constructor for each decorated property: String, Number, Boolean, Array, Object or a class. Most of the detail is lost. Tag[] becomes Array with no element type. Paginated<User> becomes the bare class or Object. 'a' | 'b' collapses to String or Object. The ? marker leaves no trace at all. A property with no decorator emits no metadata, so @nestjs/swagger cannot see it. This is why an undecorated DTO renders as {}.

The Nest CLI plugin fixes most of this. It runs during compilation, while the full type information still exists, and adds the metadata that reflection would have dropped. You enable it in nest-cli.json by listing @nestjs/swagger under compilerOptions.plugins. Set introspectComments: true to turn JSDoc comments into descriptions. The plugin only processes files matching its DTO suffixes, which default to .dto.ts and .entity.ts, so check your file names.

Concern Plugin infers Declare explicitly
Property presence and type Yes, including array element types —
Required vs optional Yes, from ? —
Descriptions Yes, from comments Override when needed
Enums, enumName Partially enum, enumName
format, example, nullable Not reliably @ApiProperty
minimum, maximum, lengths Partially, via class-validator shim @ApiProperty

Circular references need extra care. When User references Team and Team references User, one class is still undefined at the moment the decorator runs. Use a lazy type function such as type: () => Team in @ApiProperty, so the reference is resolved only when the document is built.

// user.dto.ts: the .dto.ts suffix lets the CLI plugin process this file.
// The plugin infers presence, types, optionality (?) and JSDoc descriptions.
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export enum Role {
  Admin = 'admin',
  Member = 'member',
}

export class UserDto {
  /** Display name shown in the UI */
  name: string; // plugin: type string, required, description from comment

  nickname?: string; // plugin: marked optional because of `?`

  // The plugin does not reliably add format, example or nullable.
  @ApiProperty({ format: 'email', example: 'jane@example.com' })
  email: string;

  // enumName registers a reusable components.schemas.Role entry.
  @ApiProperty({ enum: Role, enumName: 'Role' })
  role: Role;

  @ApiPropertyOptional({ minimum: 0, maximum: 150, nullable: true })
  age?: number | null;

  // TeamDto is declared below, so it is undefined when this decorator runs.
  // The lazy function defers the lookup until the document is built.
  @ApiProperty({ type: () => TeamDto })
  team: TeamDto;
}

export class TeamDto {
  name: string;

  // The back-reference to UserDto also uses a lazy type function.
  @ApiProperty({ type: () => UserDto, isArray: true })
  members: UserDto[];
}

A DTO where the CLI plugin infers names, types, optionality and comment descriptions, while explicit @ApiProperty options add format, example, enum, nullable and bounds, and lazy type functions resolve the User/Team circular reference.

Documenting responses, errors and generic envelopes

Return types are a weak source of truth. With emitDecoratorMetadata, the reflected return type of an async handler is just Promise, which says nothing about the payload. The CLI plugin reads the TypeScript signature and does better, but it still cannot help with interfaces, which have no runtime class to point at. Generic wrappers like Paginated<UserDto> are also out of reach, because the type parameter is erased before the decorator runs. Declare responses explicitly, and treat inference as a fallback.

@ApiResponse({ status, type, description }) is the general form. Shorthands such as @ApiOkResponse, @ApiCreatedResponse and @ApiNotFoundResponse fix the status code and read better in review. For errors, define a single ErrorResponseDto that mirrors what your exception filter actually returns, such as statusCode, message and error. Reference it from every error decorator. Response decorators work at class level too, so shared cases like 401 and 403 can be declared once per controller. If the DTO does not match the real filter output, the docs give clients a false contract.

Paginated envelopes need composition. Define a PaginatedDto with total, limit, offset and an untyped results array. Register the item model with @ApiExtraModels, because a class that no property references never reaches components.schemas. Then build the schema with allOf: a $ref from getSchemaPath(PaginatedDto), plus an override that sets results.items to getSchemaPath(model). Wrap all of this in applyDecorators as ApiPaginatedResponse(model) so that every list endpoint uses the same envelope.

Before relying on composition, check your client generator. Generators treat allOf and oneOf differently. Some flatten them into a single class, some emit inheritance, and some drop the override or fall back to an untyped value. Generate a client from your spec and inspect the output before you commit to this pattern.

import { applyDecorators, Controller, Get, Param, Type } from '@nestjs/common';
import { ApiExtraModels, ApiNotFoundResponse, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';

// Mirrors what the exception filter actually returns. Keep the two in sync.
export class ErrorResponseDto {
  @ApiProperty({ example: 404 }) statusCode!: number;
  @ApiProperty({ example: 'User not found' }) message!: string;
  @ApiProperty({ example: 'Not Found' }) error!: string;
}

export class UserDto {
  @ApiProperty() id!: string;
  @ApiProperty() email!: string;
}

// Envelope with an untyped results array. Each endpoint fills in the item type.
export class PaginatedDto<T> {
  @ApiProperty() total!: number;
  @ApiProperty() limit!: number;
  @ApiProperty() offset!: number;
  results!: T[];
}

export const ApiPaginatedResponse = <TModel extends Type<unknown>>(model: TModel) =>
  applyDecorators(
    // Without this, the envelope and the item model never reach components.schemas.
    ApiExtraModels(PaginatedDto, model),
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(PaginatedDto) },
          { properties: { results: { type: 'array', items: { $ref: getSchemaPath(model) } } } },
        ],
      },
    }),
  );

@Controller('users')
export class UsersController {
  @Get() @ApiPaginatedResponse(UserDto)
  list(): Promise<PaginatedDto<UserDto>> { /* ... */ return Promise.resolve({ total: 0, limit: 20, offset: 0, results: [] }); }

  @Get(':id') @ApiOkResponse({ type: UserDto }) @ApiNotFoundResponse({ type: ErrorResponseDto })
  findOne(@Param('id') id: string): Promise<UserDto> { /* ... */ return Promise.resolve({ id, email: 'user@example.com' }); }
}

A shared ErrorResponseDto, a PaginatedDto envelope, and a reusable ApiPaginatedResponse decorator that uses ApiExtraModels, getSchemaPath and allOf to declare the type of each item in results.

Keeping DTO variants, validation and docs in sync

Generating the client is also how you catch the next problem: mapped types imported from the wrong package. @nestjs/mapped-types and @nestjs/swagger both export PartialType, PickType, OmitType and IntersectionType. Both copy class-validator and class-transformer metadata to the derived class, so validation works either way. Only the @nestjs/swagger versions also copy the OpenAPI property metadata. If you write UpdateUserDto extends PartialType(CreateUserDto) with the mapped-types import, requests still validate correctly. The UpdateUserDto schema, however, comes out empty or contains only the properties declared directly on it. Nothing errors and nothing warns. To catch this, add a lint rule that bans importing these helpers from @nestjs/mapped-types in any project that also generates a spec.

Constraint drift

Validation and documentation are declared separately, so they can disagree. Suppose @MaxLength(255) is paired with @ApiProperty({ maxLength: 255 }). Someone tightens the validator to @MaxLength(100) and leaves the doc decorator alone. The spec still advertises 255. A generated client or form that trusts the spec then sends a 180-character value, and the server answers with a 400 error that the published contract says should not happen.

The CLI plugin's class-validator shim can infer a subset of constraints from validator decorators. Check which ones it covers before you rely on it. For everything else, keep the paired decorators on adjacent lines so a reviewer sees both in the same diff.

The cost is real. A heavily constrained DTO can carry three or four decorators per field, and each rule is stated twice. Accept that duplication only where the plugin can't infer the constraint.

// user.dto.ts
import { ApiProperty, PartialType } from '@nestjs/swagger';
// WRONG for docs: import { PartialType } from '@nestjs/mapped-types';
// That version copies validation metadata but not OpenAPI metadata,
// so the UpdateUserDto schema would silently lose inherited properties.
import { IsEmail, IsString, MaxLength } from 'class-validator';

export class CreateUserDto {
  // Keep each validator next to its doc decorator so one diff shows both.
  @IsEmail()
  @ApiProperty({ format: 'email', example: 'jane@example.com' })
  email: string;

  // Drift example: the validator was tightened from 255 to 100,
  // but the doc decorator still says 255. The spec now promises
  // that 180 characters is valid, and the server rejects it with a 400.
  @IsString()
  @MaxLength(100)
  @ApiProperty({ maxLength: 255 }) // BUG: should be maxLength: 100
  displayName: string;
}

// Imported from @nestjs/swagger: validation rules AND schema properties
// are inherited, and every field is marked optional in the spec.
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// .eslintrc.js (excerpt): stop the wrong import from coming back.
// rules: {
//   'no-restricted-imports': ['error', {
//     paths: [{
//       name: '@nestjs/mapped-types',
//       message: 'Import mapped types from @nestjs/swagger so the OpenAPI schema is inherited.',
//     }],
//   }],
// }

This example imports PartialType from @nestjs/swagger so the update DTO keeps its documented properties, shows a MaxLength rule that has drifted from its matching ApiProperty constraint, and adds an ESLint no-restricted-imports rule that blocks the @nestjs/mapped-types import.

Enums, discriminated unions, file uploads and auth

Generated clients fail most often on the following four cases. Reflection gives the generator nothing useful for any of them, so each needs explicit metadata.

Enums. Without enumName, every property that uses an enum gets its own inline list of values. Client generators then emit duplicate types such as StatusEnum, Status1Enum and so on. Setting enumName on @ApiProperty creates a single named schema component, so every field references the same type.

Discriminated unions. A TypeScript union type is erased at runtime and cannot be reflected. Register each variant with @ApiExtraModels, then describe the property with oneOf built from getSchemaPath. Add a discriminator with propertyName and mapping. The mapping matters because without it, generators fall back to trying each schema in turn or produce an untyped object.

File uploads. @ApiConsumes('multipart/form-data') sets the content type. @ApiBody describes the file field as type: 'string', format: 'binary'. Clients use that format to send a stream or blob instead of a JSON string.

Auth. addBearerAuth() on DocumentBuilder defines the security scheme. @ApiBearerAuth() applies it to a controller or handler. Generated clients rely on this to add the Authorization header.

Case Decorators / builder calls What breaks without them
Named enums @ApiProperty({ enum, enumName }) Duplicate enum types
Discriminated unions @ApiExtraModels, oneOf, getSchemaPath, discriminator Untyped or ambiguous objects
File uploads @ApiConsumes('multipart/form-data'), @ApiBody with format: 'binary' File sent as a string
Bearer auth addBearerAuth(), @ApiBearerAuth() No auth header in clients

In production, either skip SwaggerModule.setup or put the UI behind authentication. A public UI exposes your full API surface to anyone who finds it. Consumers should use the exported static spec instead.

Stable operationIds, spec export and CI checks

Generated clients take their method names from operationId, so an unstable ID becomes a breaking change for every consumer. By default the ID is derived from the controller class name and the method name. Renaming UsersController therefore renames methods in every SDK, even if no route changed. Pass an operationIdFactory in the options to SwaggerModule.createDocument and build the ID from values you treat as public contract: the tag, the method key and, when you use URI or header versioning, the version argument the factory receives. Set the global prefix before you build the document, because paths change when the prefix does. Use one @ApiTags value per controller so clients get predictable groupings.

Export the spec without serving it

Write a small script that creates the application with NestFactory.create, calls createDocument, writes the result to openapi.json with stable key ordering and then calls app.close(). It never calls listen, so no port is bound. Module initialisation still runs, though. Make database and queue connections lazy, or replace them in this script, so the script also works in CI.

Diff and lint in CI

Commit the file. In CI, regenerate it and fail the job if it differs from the committed copy. This makes every contract change show up in review. Then compare the new file against the version on the main branch with an OpenAPI diff tool that classifies breaking changes, and run a linter to enforce naming and documentation rules.

A code-first spec describes the behaviour you declared, not the bytes your API returns. Interceptors that wrap responses, exception filters that reshape errors and @Exclude on response classes can all diverge from the document without warning. Add contract tests that send real requests and validate each response body against the schema in openapi.json.

What are the alternatives?

Decorator-driven generation with @nestjs/swagger is one of several ways to get an API contract. The main alternatives are:

  • Design-first, hand-written spec. You write the OpenAPI document directly, review it as the contract, and implement against it. You can generate server-side types or stubs from the spec, or validate incoming requests against it at runtime. The spec is exactly what you intended, but nothing ties it to the code automatically, so you need contract tests to catch divergence.
  • Raw schemas inside Nest decorators. @ApiBody({ schema: { ... } }) and @ApiResponse({ schema: { ... } }) accept plain OpenAPI schema objects. This is useful for one-off shapes that don't map well to a class, but the schema is no longer linked to a DTO, so it drifts the same way a hand-written spec does.
  • Schema-library-driven validation and docs. Instead of class-validator plus @ApiProperty, you define each shape once in a runtime schema library and convert it to JSON Schema for the spec. This removes the duplicated constraint declarations described above, but it moves you away from the class-based DTO style that Nest's validation pipe and the CLI plugin are built around, and the conversion step has its own coverage gaps to check.
  • No machine-readable spec. Prose documentation and hand-written clients. This costs nothing up front, but every consumer re-implements request and response types by hand, and nothing detects when the docs go stale.

Within @nestjs/swagger itself, the choice is how much to rely on the CLI plugin versus explicit decorators. The table in the next section compares these options.

When to use code-first generation, when to avoid it, and the cost trade-offs

Code-first fits when one team owns both the service and its main consumers and the API changes often. The document is a by-product of code you already write, so it stays current as long as the CI checks run. Design-first fits when the contract itself is the deliverable: public APIs, partner integrations, or several teams that must agree on payloads before anyone implements them. If the spec must be reviewed and signed off before any code exists, code-first is the wrong tool, because you would write stub controllers only to produce a reviewable document. A common split is design-first for the external surface and code-first for internal services.

Approach Fits Main cost
Design-first (hand-written spec) Public or multi-team APIs, contract review before implementation Keeping the implementation conformant; needs contract tests
Code-first, plugin only Fast iteration, internal APIs Opaque inference; the plugin becomes a build dependency
Code-first, explicit decorators Teams that want everything visible in review Decorators duplicate types and drift when nobody updates them
Plugin plus targeted decorators Most NestJS services You must review the exported spec diff to see what was inferred

Because the plugin's output depends on compiler and plugin settings, review the diff of the exported document rather than the controller code.

Illustrative estimate (assumed numbers, not a benchmark)

Net hours per month = (consumers × client hours avoided) + (integration bugs avoided × hours per bug) − decorator maintenance − spec-diff review

Assumed inputs: 3 consumers × 4 h = 12, plus 2 bugs × 3 h = 6, minus 5 h maintenance, minus 3 h review, which gives 10 h saved per month. If you have one consumer and a stable API, the result can be negative.

How it saves engineering time

The savings come from removing manual steps that otherwise repeat for every endpoint and every consumer:

  • No hand-written client types. Consumers generate typed clients from openapi.json instead of re-declaring request and response shapes. When a DTO changes, they regenerate and the compiler points at every call site that needs an update, rather than someone finding the mismatch at runtime.
  • Docs that don't need a separate update. The spec is rebuilt from the same DTO classes the handlers use, so adding a field to a DTO updates the documentation in the same commit. Nobody has to remember to edit a separate document.
  • Breaking changes caught in review. The committed spec and the CI diff turn a renamed property, a changed operationId or a removed endpoint into a visible change in a pull request, before it reaches a consumer.
  • Less repetition per endpoint. Shared pieces such as ErrorResponseDto, class-level 401/403 responses and composite decorators like ApiPaginatedResponse are written once and reused, so documenting a new list endpoint is one decorator line.
  • Faster manual testing. Swagger UI on a development instance gives the team a ready-made way to send requests with the right shapes and auth header, without maintaining a separate collection of example requests.

These savings depend on the practices above. Without the CLI plugin settings checked, stable operationIds, the CI diff and contract tests, the generated spec can drift from real behaviour. Consumers then lose time debugging the docs instead of saving it. The illustrative estimate in the previous section shows how to weigh these savings against the maintenance and review cost for your own team.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs swagger openapi, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.