OpenAPI + NestJS: type-safe controllers from the contract

NestJS defaults to code-first: you write controllers, decorate them, and generate the spec from code. The spec is a byproduct. But those decorators are runtime metadata, so TypeScript doesn’t check them against your actual return types. This article flips the flow. We generate controller method types from an OpenAPI spec and use implements to make the compiler enforce the contract.
Other parts:
- API contracts and everything I wish I knew: a frontend survival guide
- Contract shock therapy: the way to API-first documentation bliss
- Life's too short to hand-write API types: OpenAPI-driven React
- OpenAPI + Fastify: let the contract build your server
- OpenAPI + NestJS: type-safe controllers from the contract
This is part five of our contract-first series (part one | part two | part three | part four), but works standalone if you’re here to wire OpenAPI into a NestJS backend.
Hire Evil Martians
We'll build your contract-first backend so integration becomes a non-event.
Table of contents:
- NestJS teaches you code-first, and it works - until it doesn’t
- The decorator layer is documentation, not enforcement
- What if the spec came first?
- Generate controller method types from the spec
- implements: the compiler becomes your contract enforcer
- What this doesn’t do (and why that’s fine)
- Where this fits in the contract-first stack
NestJS teaches you code-first, and it works (until it doesn’t)
The NestJS way of doing OpenAPI starts with code. You write a DTO, add @ApiProperty() to every field, annotate your controller methods with @ApiOperation() and @ApiResponse(), then generate the spec with @nestjs/swagger. The spec falls out of the implementation:
// The standard NestJS code-first flow
export class CreatePetDto {
@ApiProperty({ description: 'Name of the pet' })
@IsString()
name: string;
@ApiProperty({ required: false })
@IsOptional()
tag?: string;
}For a single team on a single service this works. The spec is documentation. Nobody depends on it for code generation or contract enforcement. It gets served at /api-docs and that’s it. But the moment a frontend team starts generating types from that spec, or a mobile team builds against it, or an external consumer integrates with it-the spec is a shared contract. And you have three artifacts that can drift: the actual controller code, the decorator metadata, and the generated spec. None of them is canonically “the truth”.
We covered the broader problems with code-first in part one of this series. NestJS makes them concrete: the gap between “what the code does” and “what the spec says” is bridged entirely by metadata that TypeScript doesn’t type-check. This isn’t hypothetical. The NestJS community has been asking for contract-first support since 2020, and the issue is still open.
The decorator layer is documentation, not enforcement
Here’s something that compiles and runs without a single TypeScript error:
@Controller('pets')
export class PetsController {
@Get()
@ApiResponse({ status: 200, type: [PetDto] })
async listPets() {
return { oops: 'this is not an array of PetDto' };
}
}The @ApiResponse decorator says this endpoint returns PetDto[]. The method returns { oops: string }. TypeScript doesn’t care. The decorator is runtime metadata consumed by @nestjs/swagger to build the spec. It has no effect on the type system.
This means every @ApiProperty(), every @ApiResponse(), every @ApiOperation() you write is a promise you make to Swagger UI. Not to the compiler. You can break that promise and nothing catches it until a consumer hits the endpoint and gets unexpected data back.
Decorators in TypeScript are runtime functions. They run when the class is loaded. They can store metadata, modify prototypes, do whatever they want at runtime. What they cannot do is change the type signature of the thing they decorate. So @ApiResponse({ type: PetDto }) on a method that returns string is perfectly valid TypeScript. The type system sees a method returning string. The decorator is just… there.
What if the spec came first?
Parts one and two of this series covered why writing the spec first changes things. We’re not going to rehash that argument here.
The practical question for NestJS: if you already have an OpenAPI spec, how do you connect it to your controllers?
In part four we did this with Fastify. The fastify-openapi-glue library read the spec and wired routes automatically. Handlers got full type coverage: params, body, response. NestJS doesn’t have an equivalent. The framework owns routing through its decorator system. @Get(), @Post(), @Controller('pets'), that’s how NestJS works and no codegen tool is going to replace it.
So we aim for something smaller but still valuable: compile-time type checking for controller method signatures. The spec defines what each method should accept and return. TypeScript checks that your controller matches.
Generate controller method types from the spec
Hey API is the OpenAPI codegen tool we’ve been using throughout this series. It didn’t have NestJS support—so we built it. The nestjs plugin we contributed to Hey API generates controller method types from your spec. Adding it is one line in the config:
// openapi-ts.config.ts
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: './openapi.json',
output: {
path: './src/client',
},
plugins: ['nestjs', '@hey-api/sdk'],
});Run npx openapi-ts and the plugin generates a nestjs.gen.ts file. For a Petstore-style spec with pets and store tags, you get:
// src/client/nestjs.gen.ts (auto-generated)
export type PetsControllerMethods = {
listPets: (query?: ListPetsData['query']) => Promise<ListPetsResponse>;
createPet: (body: CreatePetData['body']) => Promise<CreatePetResponse>;
deletePet: (path: DeletePetData['path']) => Promise<DeletePetResponse>;
showPetById: (path: ShowPetByIdData['path']) => Promise<ShowPetByIdResponse>;
updatePet: (
path: UpdatePetData['path'],
body: UpdatePetData['body'],
) => Promise<UpdatePetResponse>;
};
export type StoreControllerMethods = {
getInventory: () => Promise<GetInventoryResponse>;
};Each OpenAPI operation becomes a method. The operationId becomes the method name. Parameters are split by location (path, query, body) matching how NestJS extracts them with @Param(), @Query(), @Body(). Required parameters come before optional ones. Return types are wrapped in Promise because NestJS controller methods are async.
The types import from types.gen.ts which are the same generated types your frontend can use (see part three). One spec, both sides of the stack.
Notice what isn’t in the generated file: no import from @nestjs/common. No decorators. No framework dependency at all. These are pure TypeScript types. The NestJS wiring stays in your controller where it belongs.
implements: the compiler becomes your contract enforcer
Here’s where NestJS gives us something Fastify couldn’t. In part four, we typed Fastify handlers with a RouteHandlers interface on a plain object. Works. But NestJS controllers are classes. And TypeScript classes support implements:
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Post,
Query,
} from '@nestjs/common';
import type { PetsControllerMethods } from '../client/nestjs.gen';
import type {
CreatePetData,
ListPetsData,
Pet,
ShowPetByIdData,
} from '../client/types.gen';
@Controller('pets')
export class PetsController
implements
Pick<PetsControllerMethods, 'createPet' | 'listPets' | 'showPetById'>
{
private readonly pets: Pet[] = [
{ id: '1', name: 'Fido', status: 'available', tag: 'dog' },
{ id: '2', name: 'Kitty', status: 'available', tag: 'cat' },
];
@Get()
async listPets(@Query() query?: ListPetsData['query']) {
const limit = query?.limit ?? 20;
return this.pets.slice(0, limit);
}
@Post()
async createPet(@Body() body: CreatePetData['body']) {
const pet: Pet = {
id: crypto.randomUUID(),
name: body.name,
status: 'available',
tag: body.tag,
};
this.pets.push(pet);
return pet;
}
@Get(':petId')
async showPetById(@Param() path: ShowPetByIdData['path']) {
const pet = this.pets.find((p) => p.id === path.petId);
if (!pet) {
throw new NotFoundException(`Pet ${path.petId} not found`);
}
return pet;
}
}Pick<PetsControllerMethods, 'createPet' | 'listPets' | 'showPetById'> selects only the methods this controller handles. You don’t have to implement every operation from the spec. Maybe some operations live in a different controller. Maybe you haven’t built them yet. Pick lets you adopt incrementally, so you can add methods to the union as you implement them.
Now, change the spec. Rename a response field. Add a required parameter. Change a return type. Regenerate. The compiler immediately tells you which controllers don’t match:
// If the spec changes showPetById to require a 'query' parameter:
// error TS2420: Class 'PetsController' incorrectly implements interface
// 'Pick<PetsControllerMethods, "createPet" | "listPets" | "showPetById">'.
// Property 'showPetById' is incompatible...The build fails. Before you ever push to production or staging.
The one thing that changes: parameter style
For implements to work, your controller methods need to match the generated signatures. That means switching from per-field parameter extraction to whole-object style:
// BEFORE - the style most NestJS devs use
@Get(':petId')
async showPetById(@Param('petId') petId: string) {
const pet = this.pets.find((p) => p.id === petId);
// ...
}
// AFTER - whole-object style, works with implements
@Get(':petId')
async showPetById(@Param() path: ShowPetByIdData['path']) {
const pet = this.pets.find((p) => p.id === path.petId);
// ...
}The change is small. Instead of @Param('petId') petId: string you write @Param() path: ShowPetByIdData['path'] and access path.petId. Same for @Query() and @Body(). The NestJS docs cover this style—it’s just less common than per-field extraction.
This is the tradeoff. You give up the convenience of named parameter decorators. You get compile-time contract enforcement in return. For us, it was worth it.
What this doesn’t do (and why that’s fine)
The plugin generates types. That’s it. It doesn’t do routes, validation, or Swagger UI.
You’ll still write @Get(), @Post(), @Controller('pets') yourself. NestJS owns routing through decorators. No codegen tool can replace that without replacing the framework. And if you need Swagger UI served from your NestJS app, you still use @nestjs/swagger. The plugin doesn’t generate documentation, it generates type constraints.
Runtime validation is also outside the scope. So if a client sends { name: 123 } instead of { name: "Fido" }, the plugin won’t catch it. That’s still a job for class-validator, Zod pipes, or whatever validation layer you already have.
What the plugin does is narrow: compile-time contract compliance. Your controller method signatures must match the spec. If they don’t, the build fails. NestJS already has good answers for routing and validation and documentation. The missing piece was “does my controller actually match the contract?” Now there’s an answer for that too.
Where this fits in the contract-first stack
Over this series we’ve built a contract-first workflow piece by piece. The spec lives in a dedicated repository as the single source of truth. The frontend generates TypeScript types, SDK functions, and MSW mocks from it. A Fastify backend can wire routes and validate requests from the same spec. And now NestJS controllers can enforce method signatures against it at compile time.
Same spec. Different tools on each side. Change the spec, regenerate, fix the type errors. That’s the whole workflow.
We built the NestJS plugin at Evil Martians and contributed it to Hey API because the NestJS ecosystem didn’t have a spec-first type enforcement tool. It’s open source and documented. The code-first flow is fine for many projects. But when the spec is a shared contract between teams, having the compiler verify your controllers against it saves a lot of late-night debugging.


