Skip to main content

Generating OpenAPI From Code With Decorators

The canonical failure mode of a code-first API is a hand-maintained OpenAPI document. Engineers add a route, forget to update the YAML, and the published contract silently disagrees with what the server actually serves. Clients break. Support tickets open. The fix is to remove the human from the loop entirely: derive the spec mechanically from the TypeScript decorators that already describe the code, commit that generated file, and fail any CI run where the two disagree. This guide is a focused fix for that drift problem and extends the broader Schema-First vs Code-First Workflows topic with step-by-step implementation for two production-grade generators: @nestjs/swagger 7.x (for NestJS teams) and tsoa 6.x (for Express or framework-agnostic teams).

Decorator-to-OpenAPI generation pipeline Annotated TypeScript DTOs and controllers flow into a spec generator (NestJS DocumentBuilder or tsoa spec). The generator emits openapi.json, which is committed to the repository. A CI gate regenerates the spec on every pull request and runs git diff --exit-code to fail if the committed file is stale. TypeScript source @ApiProperty DTOs @ApiResponse ops source of truth Generator NestJS bootstrap or tsoa spec prebuild step openapi.json committed to repo reviewable in PRs generated artifact CI Gate regenerate spec → git diff --exit-code openapi.json

Edit the code. Never hand-edit the spec. Let CI enforce the rule.

Symptom: The Hand-Maintained Spec Drifts

The failure presents in several recognizable ways:

  • A client SDK regenerated from openapi.yaml compiles cleanly but throws TypeError: Cannot read properties of null at runtime because the spec marks a field non-nullable but the server can return null.
  • A consumer’s Pact test fails on provider verification for an endpoint that was refactored two sprints ago — the spec was never updated.
  • spectral lint openapi.yaml passes but a response body in staging contains a field the spec does not document, meaning either the spec is incomplete or the handler has grown beyond what was annotated.

In each case the root cause is the same: the spec was edited by hand (or not edited at all) while the code changed. The document claims to describe the API; it does not.

Root Cause

A hand-maintained OpenAPI document is a second representation of the same facts already expressed in code. Two representations of the same facts diverge. This is not a discipline problem; it is an architectural one. The fix is not to be more careful — it is to eliminate one of the two representations by generating the spec mechanically from the code that is already the authoritative source.

The secondary cause is that even teams who use a generator often do not commit the output or gate it in CI. The spec is regenerated at preview time, never checked in, and never compared to anything. Drift still accumulates because nobody sees the difference between yesterday’s generator output and today’s.

The solution has three parts: annotate the code so the generator has complete information, write the generated spec to a committed file, and fail CI when that file is stale.

Step-by-Step Fix

Step 1: Annotate DTOs With Schema Decorators

The generator can only emit what the decorators declare. A DTO property with no decorator is invisible to the spec — it appears in the TypeScript types but not in the published contract. Annotate every property that belongs in the API surface.

NestJS with @nestjs/swagger 7.x:

// src/orders/dto/order.dto.ts
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";

export class OrderDto {
  @ApiProperty({ type: String, format: "uuid", description: "Order identifier" })
  id: string;

  @ApiProperty({
    type: String,
    enum: ["pending", "shipped", "cancelled"],
    description: "Current fulfilment state",
  })
  status: "pending" | "shipped" | "cancelled";

  @ApiProperty({ type: Number, format: "double", description: "Order total in USD" })
  total: number;

  // nullable: true is required — omitting it silently removes nullability from the spec
  @ApiPropertyOptional({ type: String, nullable: true, description: "Optional internal note" })
  note: string | null | undefined;
}

Why nullable: true must be explicit: @nestjs/swagger reads TypeScript types via emitDecoratorMetadata, but TypeScript’s reflection API erases union members at runtime — it cannot distinguish string | null from string. You must repeat the nullability in the decorator argument or the spec will mark the field required and non-nullable regardless of what the type annotation says.

tsoa 6.x:

tsoa reads TypeScript types directly during its own compilation step, so nullability from string | null is captured without a separate decorator. Formats and descriptions require JSDoc or a @tsoa decorator:

// src/orders/orders.controller.ts (tsoa 6.x)
import { Controller, Get, Path, Route, SuccessResponse, Tags } from "tsoa";
import { OrderDto } from "./dto/order.dto";

/** @description Fulfilment order */
interface OrderDto {
  /** @format uuid */
  id: string;
  status: "pending" | "shipped" | "cancelled";
  /** @format double */
  total: number;
  note: string | null;  // tsoa reads the null union — no extra annotation needed
}

@Tags("Orders")
@Route("orders")
export class OrdersController extends Controller {
  /**
   * Retrieve a single order by its UUID.
   * @param orderId The order UUID
   */
  @Get("{orderId}")
  @SuccessResponse(200, "Order found")
  public async getOrder(@Path() orderId: string): Promise<OrderDto> {
    return fetchOrder(orderId);
  }
}

Why this works: tsoa’s compiler plugin resolves string | null to an OpenAPI nullable: true schema at generation time because it inspects the real TypeScript AST rather than the stripped runtime metadata. The trade-off is that tsoa requires its own compilation pass before the normal tsc run.

Step 2: Annotate Controllers With Operation Decorators

DTOs describe shapes; controller decorators describe operations — which path, which HTTP method, which status codes, and which error responses. Missing operation annotations produce endpoints absent from the spec or operations with undocumented error responses, which breaks client generation and consumer-driven contract tests.

NestJS:

// src/orders/orders.controller.ts (@nestjs/swagger 7.x)
import {
  Controller, Get, Param, HttpCode, HttpStatus,
} from "@nestjs/common";
import {
  ApiTags, ApiOperation, ApiParam, ApiResponse, ApiNotFoundResponse,
} from "@nestjs/swagger";
import { OrderDto } from "./dto/order.dto";
import { ProblemDto } from "../common/dto/problem.dto";

@ApiTags("Orders")
@Controller("orders")
export class OrdersController {
  @Get(":orderId")
  @HttpCode(HttpStatus.OK)
  @ApiOperation({
    operationId: "getOrder",
    summary: "Retrieve a single order",
    description: "Returns the order matching the provided UUID.",
  })
  @ApiParam({ name: "orderId", schema: { type: "string", format: "uuid" } })
  @ApiResponse({ status: 200, description: "Order found", type: OrderDto })
  @ApiNotFoundResponse({ description: "No order with that ID", type: ProblemDto })
  async getOrder(@Param("orderId") orderId: string): Promise<OrderDto> {
    return this.ordersService.findOne(orderId);
  }
}

Every @ApiResponse call maps directly to a response object in the emitted spec. Skipping the 404 decorator means the spec claims this endpoint never returns 404 — consuming clients will not generate error-handling code for that branch.

Step 3: Wire the Emit Step Into the Build

The generator must write openapi.json to a predictable path on every build so there is always a fresh file to commit and gate.

NestJS — standalone bootstrap script:

// scripts/generate-spec.ts  (run with ts-node or tsx)
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { writeFileSync } from "node:fs";
import { AppModule } from "../src/app.module";

async function generate(): Promise<void> {
  // createApplicationContext boots the DI container without starting an HTTP server
  const app = await NestFactory.createApplicationContext(AppModule, {
    logger: false,
  });

  const config = new DocumentBuilder()
    .setTitle("Orders API")
    .setDescription("Internal order management service")
    .setVersion("2.3.0")
    .addBearerAuth()
    .build();

  // SwaggerModule.createDocument traverses all controllers registered in AppModule
  const document = SwaggerModule.createDocument(app as any, config);

  writeFileSync(
    "./openapi.json",
    JSON.stringify(document, null, 2),
    "utf8",
  );

  await app.close();
  console.log("openapi.json written");
}

generate().catch((err) => {
  console.error(err);
  process.exit(1);
});

Add it to package.json as a prebuild script so it runs automatically before every build:

{
  "scripts": {
    "generate:spec": "tsx scripts/generate-spec.ts",
    "prebuild": "npm run generate:spec",
    "build": "nest build"
  }
}

tsoa — tsoa.json configuration:

{
  "entryFile": "src/app.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "controllerPathGlobs": ["src/**/*.controller.ts"],
  "spec": {
    "outputDirectory": ".",
    "specFileName": "openapi.json",
    "specVersion": 3,
    "openApiVersion": "3.0.3",
    "info": {
      "title": "Orders API",
      "version": "2.3.0"
    },
    "securityDefinitions": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    }
  },
  "routes": {
    "routesDir": "src/generated"
  }
}
{
  "scripts": {
    "generate:spec": "tsoa spec",
    "generate:routes": "tsoa routes",
    "generate": "npm run generate:spec && npm run generate:routes",
    "prebuild": "npm run generate",
    "build": "tsc"
  }
}

Why this works: both approaches produce a deterministic JSON file from the same annotated source. The output is committed to the repository so every PR diff shows exactly what changed in the contract, and downstream consumers — mock servers, type generators, Pact provider tests — can read the spec without running the build.

Step 4: Gate the Committed Spec in CI

The gate has one job: fail any pull request where the committed openapi.json does not match what the generator would produce from the current code. It does this by regenerating in CI and comparing the result.

# .github/workflows/spec-gate.yml
name: OpenAPI Spec Gate
on: [pull_request]

jobs:
  spec-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      # npm ci installs EXACTLY the pinned versions — essential for deterministic output
      - run: npm ci

      # Regenerate the spec from the current annotated code
      - name: Regenerate OpenAPI spec
        run: npm run generate:spec

      # Fail if the regenerated file differs from what was committed
      - name: Gate spec drift
        run: |
          git diff --exit-code openapi.json \
            || (echo "::error::openapi.json is stale. Run 'npm run generate:spec' locally and commit the result." && exit 1)

      # Optional: lint the emitted spec for structural validity
      - name: Lint spec
        run: npx @stoplight/spectral-cli@6.11 lint openapi.json --ruleset .spectral.yaml

Why git diff --exit-code and not a checksum: a straight diff produces readable output in the CI log that shows exactly which field changed, which operation was added, and which type narrowed. A hash comparison tells you something changed; the diff tells you what, which makes the failure actionable without checking out the branch locally.

Before/After Comparison

Before — hand-maintained spec, missing nullability:

# openapi.yaml — authored by hand, two weeks behind the code
components:
  schemas:
    Order:
      type: object
      required: [id, status, total]
      properties:
        id:
          type: string
        status:
          type: string
        total:
          type: number
        # note field omitted — was added to the code last sprint, nobody updated the spec

After — generated spec, complete and accurate:

{
  "components": {
    "schemas": {
      "OrderDto": {
        "type": "object",
        "required": ["id", "status", "total"],
        "additionalProperties": false,
        "properties": {
          "id":     { "type": "string",  "format": "uuid",   "description": "Order identifier" },
          "status": { "type": "string",  "enum": ["pending", "shipped", "cancelled"] },
          "total":  { "type": "number",  "format": "double", "description": "Order total in USD" },
          "note":   { "type": "string",  "nullable": true,   "description": "Optional internal note" }
        }
      }
    }
  }
}

The generated spec includes the note field, the uuid format, the nullable flag, and the closed additionalProperties: false — none of which appeared in the hand-authored version because they required extra effort that accumulated on the backlog and never happened.

Verification

Confirm the pipeline is wired correctly with three commands, each a hard exit-code check:

# 1. Generate and confirm the file was written
npm run generate:spec
test -f openapi.json && echo "spec generated"

# 2. Confirm the committed spec matches what the generator produces (must be zero diff)
git diff --exit-code openapi.json && echo "spec in sync"

# 3. Confirm the spec is structurally valid
npx @stoplight/spectral-cli@6.11 lint openapi.json && echo "spec valid"

A clean run through all three produces spec generated, spec in sync, and spec valid with exit code 0. In CI the gate job shows a green checkmark and the PR diff on openapi.json shows exactly the contract changes introduced by the PR — reviewable alongside the code changes that caused them.

To simulate a drift failure, change a DTO property type without regenerating and then run git diff --exit-code openapi.json. It returns non-zero and prints the diff to stdout. That is the same signal the CI gate emits when a developer forgets to run generate:spec before pushing.

Edge Cases and Caveats

Generics. Both @nestjs/swagger and tsoa handle concrete generic instantiations — Page<OrderDto> — by inlining the schema. @nestjs/swagger requires you to declare the generic wrapper explicitly with ApiExtraModels and reference it via getSchemaPath; forgetting this produces an empty {} schema. tsoa handles generics more transparently but still rejects uninstantiated type parameters used as return types. Always test a new generic DTO by regenerating and inspecting the emitted components.schemas section.

Enums. @nestjs/swagger emits string enums as { type: "string", enum: ["pending", ...] } by default. If you also use @IsEnum from class-validator, the two must agree — a mismatch causes runtime rejections for values the spec claims are valid. tsoa emits both the string values and, optionally, a TypeScript const enum reference. For compile-time type generation from OpenAPI, string-literal union schemas (enum: [...]) are preferable to $ref-based enum schemas because openapi-typescript generates tree-shakeable union types from them rather than runtime objects.

Polymorphism limits. Code-first generators struggle with oneOf/discriminator compositions. @nestjs/swagger can emit a oneOf via @ApiExtraModels and an explicit schema override in @ApiResponse, but the result is verbose and error-prone — a missed discriminator.propertyName produces a spec that validators and SDK generators reject. tsoa supports discriminated unions through TypeScript union types, but the emitted discriminator.mapping requires manual verification against the actual subtype schemas. For APIs where polymorphism is central, the step-by-step guide to schema-first API development approach — authoring the discriminator in YAML first and generating code from it — produces more reliable results than the inverse.

Frequently Asked Questions

Does NestJS @nestjs/swagger generate a fully valid OpenAPI 3.0 document?

@nestjs/swagger 7.x emits a valid OpenAPI 3.0 document by default. OpenAPI 3.1 support requires an explicit swagger-parser upgrade and is still maturing; for 3.1-only features such as type arrays, tsoa or a hand-authored spec is currently safer.

Can I use tsoa with an existing Express app without migrating to a full framework?

Yes. tsoa generates Express-compatible route files alongside the spec. Your existing middleware and error handling remain untouched; tsoa only owns the typed controller layer and the spec emit.

Why does my CI diff fail even though I ran the generator locally before committing?

The most common cause is a version mismatch between the locally installed generator and the one npm ci installs in CI. Pin exact versions (no caret) in package.json and run npm ci both locally and in CI to guarantee byte-identical output.

How do I add a custom OpenAPI extension (x-) using NestJS decorators?

Use the @ApiExtension('x-your-key', value) decorator from @nestjs/swagger on a controller method or DTO property. Extensions are included in the emitted document and survive regeneration.

Does tsoa handle TypeScript generic types in DTOs?

tsoa resolves concrete instantiations of generics (e.g. Page<Order>) into inline schemas at generation time. An uninstantiated generic used directly as a return type is rejected with a clear error; you must supply the type argument.