Kontract · NestJS
Bind controllers to generated operations.
Generate one operation contract per API route, then let the public Kontract ESLint rule catch controller drift in editors and CI.
1 · Generate the contract
Enable operation metadata on the NestJS target.
zod: true emits framework-neutral Zod schemas and inferred types, while dto: zod emits the NestJS Zod DTO constructors. operationContract: true emits http/operations.ts with generated operation constants and ContractOperation. JSON operations need exactly one generated @ContractOperation(Operation) decorator. It invokes native nestjs-zod ZodResponse metadata at runtime while its generated overload preserves the handler return constraint at compile time. Do not add a sibling @ZodResponse or @ZodSerializerDto.
One normalized transport classifier feeds generated controllers, lint metadata, runtime descriptors, and test manifests. Each descriptor also keeps source provenance through its JSON Pointer so generation failures remain traceable.
kontract:
sources:
- name: api
input: schema
targets:
- name: backend
output: backend/src/shared/models
generator:
type: typescript-nestjs
zod: true
dto: zod
operationContract: truesumr kontract validate# check the schema sourcesumr kontract generate --target backend# refresh generated operationsimport { Controller, Get, Param } from '@nestjs/common';
import {
ContractOperation,
GetRecipe,
type GetRecipePayload,
} from '@sumr/schemas/recipes/http';
@Controller('recipes')
export class RecipesController {
@Get(':recipeId')
@ContractOperation(GetRecipe)
getRecipe(@Param('recipeId') recipeId: string): Promise<GetRecipePayload> {
return this.recipes.getRecipe(recipeId);
}
}2 · Bind request input
Use the operation-owned request DTO and type.
Request bodies bind through the exact generated runtime contract: @Body(new ZodValidationPipe(Operation.requestBodyDto)). Type the same parameter with its operation-owned OperationRequest input type. Required, optional, scalar, array, union, nullable, and referenced bodies then use the same schema as the operation descriptor rather than reflected class metadata.
OpenAPI object schemas are open when additionalProperties is omitted. Kontract preserves that shape in generated models and class-validator DTOs, including nested objects, so DTOs remain structurally assignable to their models. The generated index signatures are TypeScript-only: a Nest ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) still rejects undeclared keys. Declare additionalProperties: false on request schemas that must be closed at runtime.
Bind parameters separately with ContractParameterPipe, or bind a non-empty path, query, header, or cookie group through its exact generated type and ContractParameterBagPipe(Operation, location). Named and aggregate bindings retain generated enum-component reference identity instead of accepting a local type mirror. Optional and array shapes remain operation-owned too.
Supported wire input converts only declared boolean, integer, number, and array strings before applying the authoritative Zod constraints. Query form arrays accept repeated values when exploded and comma-delimited values otherwise; simple, pipeDelimited, and spaceDelimited retain their declared separators. Date and date-time values remain strings. Invalid numeric syntax, schema violations, and unknown aggregate query or path keys become canonical 400 responses.
Aggregate headers are case-insensitive. Header and cookie bags select only fields declared by the operation, so unrelated ambient values remain valid. Applications still install their own cookie parser. Object parameters, cookie arrays, and unsupported styles fail generation rather than guessing framework behavior. Supported cookies remain present in cleaned live Swagger metadata, whose cleanup restores the exact operation-owned parameter schemas after Nest reflection, including array item constraints.
import { Body, Controller, Post } from '@nestjs/common';
import { ZodValidationPipe } from 'nestjs-zod';
import {
ContractOperation,
CreateRecipe,
type CreateRecipeRequest,
type Recipe,
} from '@sumr/schemas/recipes/http';
@Controller('recipes')
export class RecipesController {
@Post()
@ContractOperation(CreateRecipe)
createRecipe(
@Body(new ZodValidationPipe(CreateRecipe.requestBodyDto))
body: CreateRecipeRequest,
): Promise<Recipe> {
return this.recipes.createRecipe(body);
}
}import { Controller, Get, Query } from '@nestjs/common';
import {
ClubsFindAll,
ContractOperation,
ContractParameterBagPipe,
type ClubsFindAllPayload,
type ClubsFindAllQuery,
} from '@sumr/schemas/clubs/http';
@Controller('clubs')
export class ClubsController {
@Get()
@ContractOperation(ClubsFindAll)
findAll(
@Query(new ContractParameterBagPipe(ClubsFindAll, 'query'))
query: ClubsFindAllQuery,
): Promise<ClubsFindAllPayload> {
return this.clubs.findAll(query);
}
}Multipart requests
Select the adapter and bound every repeated part.
There is no separate multipart flag. A component-backed multipart/form-data request activates the adapter selected by framework: express or framework: fastify. Both profiles support single files, bounded file arrays, multiple named file fields, and mixed text or JSON fields.
Every binary file schema needs a positive maxLength byte limit. File arrays and repeated non-file arrays also need a positive maxItems. Buffered adapters reject undeclared parts and enforce count, type, and size before the application port runs.
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/UploadRecipeAssets'
encoding:
metadata:
contentType: application/json
components:
schemas:
UploadRecipeAssets:
type: object
required: [documents, metadata]
properties:
documents:
type: array
maxItems: 4
items:
type: string
format: binary
maxLength: 5242880
metadata:
type: object
required: [title]
properties:
title:
type: string
tags:
type: array
maxItems: 8
items:
type: stringExpress is the default and requires @nestjs/platform-express when controllers are generated. Fastify buffers uploads through @fastify/multipart and requires @nestjs/platform-fastify, fastify, and @fastify/multipart. These generated adapters are intentionally not streaming upload APIs. dto: class-validator and dto: both require class-transformer and class-validator; tests: true requires @nestjs/testing and supertest. Install these packages in the consuming application or isolated consumer matrix, never in Kontract's root package.json.
3 · Enforce it
Use the CLI package as an ESLint plugin.
The application owns its ESLint parser, version, file scope, and normal CI command. Kontract exports the rule from @sumrco/cli/eslint; it does not copy rule source into the application or add another lint command. Map each TypeScript contract path alias to its generated output root so the rule can resolve operation metadata. The rule follows generated http/index.ts directory barrels as well as direct http/operations imports.
bun add -d @sumrco/cli eslint @typescript-eslint/parser# install development toolingimport tsParser from '@typescript-eslint/parser';
import { kontractPlugin } from '@sumrco/cli/eslint';
export default [
{
files: ['backend/src/modules/**/*.controller.ts'],
languageOptions: { parser: tsParser },
plugins: { kontract: kontractPlugin },
rules: {
'kontract/nestjs-operation-contract': ['error', {
aliases: [
{
prefix: '@sumr/schemas',
path: 'backend/src/shared/models',
},
],
}],
},
},
];What the rule checks
Keep each standard JSON route on its exact operation.
Each standard route has exactly one generated @ContractOperation(Operation) decorator.
Controller strings, Controller({ path, version }), method @Version() overrides, VERSION_NEUTRAL, and the generated URI default resolve to the selected operation path.
Named path, query, header, and cookie bindings use their exact generated primitive, optional, or array types, while enum component references retain their generated enum identity.
Aggregate parameter bindings use the operation-owned generated bag type and ContractParameterBagPipe(Operation, location).
The request body uses its generated type and operation-owned runtime pipe.
The handler return type matches the generated JSON response type.
A numeric-literal @HttpCode() does not conflict with the generated success status.
Redundant @ZodResponse() and @ZodSerializerDto() decorators are rejected because @ContractOperation already installs the exact response binding.
Library-mode @Res() does not bypass generated JSON serialization.
Multipart bodies use the generated ContractMultipartBodyPipe and the exact operation-owned mixed-field input type.
Adoption ring
Opt in only to the boundary artifacts you need.
The NestJS operation boundary is not enabled by default. Start with operationContract: true, then add the remaining flags deliberately. Kontract rejects incompatible combinations: advanced artifacts require the operation contract, foundation requires it, controllers requires the foundation, and tests requires both generated controllers and the foundation.
The versioning and defaultVersion settings are optional. Under URI versioning, method @Version() overrides the controller version, VERSION_NEUTRAL remains unversioned, and otherwise the generated target default participates in route linting without requiring a decorator on every method.
Kontract emits exactly one target-wide shared/http/foundation.ts. It owns the only ContractModule, canonical exception filter, and DI-preserving global providers, including ZodSerializerInterceptor. Each service's http/foundation.ts is a facade: it re-exports the shared boundary and retains only its service-specific buildOpenApiConfig() and cleanupOpenApiDoc(). Only JSON operations receive serialization metadata from ContractOperation; no-content, file, stream, SSE, and raw transports never install JSON serialization.
generator:
type: typescript-nestjs
zod: true
dto: zod
operationContract: true
foundation: true
controllers: true
errors: true
mappers: true
tests: true
bruno: true
framework: fastify # or express
versioning: uri
defaultVersion: '1'
envelope:
dataKey: data
metaKey: meta
staticFields:
success: true
contextFields:
timestamp: timestamp
path: path
errorEnvelope:
bodyKey: error
statusCodeKey: statusCode
messageKey: message
typeKey: error
codeKey: code
detailsKey: errors
detailsMode: raw
timestampKey: timestamp
pathKey: path
staticFields:
success: falseenvelope and errorEnvelope project fields that already exist in documented response schemas; they do not replace those schemas or invent application values. envelope.dataKey keeps ordinary handlers typed to their bare payload, while a declared metaKey adds metadata only for response schemas that contain it. Literal staticFields and request-derived contextFields must also match declared properties.
The default generated error policy is flat. A nested or otherwise custom documented error contract must map its complete errorEnvelope: bodyKey selects the nested core, staticFields supplies required outer literals, and the status, message, type, code, and details keys map the core fields. Validation details use issues-object by default or detailsMode: raw when the documented schema expects the raw value. Unexpected failures stay redacted. Context fields require foundation: true, and errorEnvelope requires errors: true. The older envelopeDataKey spelling is deprecated and cannot be combined with envelope.
zod: trueFramework-neutral Zod schemas and inferred request/response types.
dto: trueNestJS DTO constructors: none, zod, class-validator, or both.
operationContract: trueExact operation descriptors. Requires a non-none DTO mode for runtime binding.
foundation: trueOne target-wide shared boundary for DI-preserving validation, response, error, security, app, Swagger, and tests, plus per-service Swagger facades.
controllers: trueThin generated transport adapters, an application-owned port, and callback sender output when declared.
errors: trueTyped exceptions for documented non-success JSON responses.
mappers: trueExplicit one-to-one structural projections without inferred business transformations.
tests: trueProduction-parity test setup, operation manifests, and OpenAPI parity helpers. Named-example validation resolves inline values and local #/components/examples/* references; externalValue fails deterministic generation.
bruno: trueRequests and a coverage manifest keyed by exact operation identity.
Callbacks and webhooks
Keep delivery and signature policy application-owned.
Inline OpenAPI callbacks participate in the operation contract. With generated controllers enabled, Kontract emits exact callback API metadata and a typed callback sender. The application still supplies the callback URL and owns endpoint selection, signing, retries, persistence, and delivery policy. Callback request bodies must reference a generated component DTO and declare a concrete 2xx acknowledgement.
OpenAPI 3.1 top-level webhooks also join the operation spine. A webhook key beginning with / is its receiver path; otherwise declare x-kontract-webhook-path. Generated receivers fail closed until the application supplies CONTRACT_WEBHOOK_SIGNATURE_VERIFIER. Kontract does not generate signing algorithms or secrets.
Documented errors
Declare every failure the generated boundary can produce.
With foundation: true, preflight requires a documented compatible 400 response for validated input, 403 for fail-closed security, 415 for declared request media enforcement, and 500 for JSON output or unexpected failures. One compatible default error response may cover these statuses. Generation fails early rather than letting the canonical filter replace an undeclared boundary status with a misleading 500. With errors: true, typed exceptions are emitted only for documented non-success JSON responses.
sumr kontract validateruns the configured NestJS generator's compatibility preflight without writing generated files, so an error-envelope/schema mismatch fails during validation. sumr kontract generate --clean repeats that write-free preflight before deleting any selected output or Kontract cache.
Required request bodies must carry a declared Content-Type. A missing or mismatched value is rendered through the documented 415 contract. A response header with a schema constant or one enum value becomes a generated @Header; dynamic response-header values remain application-owned while their schemas stay in Swagger and the operation descriptor.
CONTRACT_ERROR_OBSERVER receives only redacted diagnostics: operation identity, route and source provenance, error category and class name, status, and schema references. It never receives the raw exception, its message, Zod input, or payload. Use CONTRACT_DOMAIN_ERROR_MAPPER when application policy needs the original domain failure to select a declared transport error.
Release verification
Verify generated adapters outside the generator dependency graph.
Kontract's release suite builds a pinned NestJS 11 Docker consumer for both Express and Fastify. Each technology-and-version matrix owns its consumer package manifest, frozen lockfile, strict TypeScript configuration, and real framework packages. The matrix generates twice to prove stable output, mounts generated files read-only, then runs compile and HTTP runtime checks with network access disabled.
Multipart scenarios send real requests through configureApp() and check exact application-port inputs, canonical 400 responses, and redacted 500 responses for malformed handler output. Generated-consumer dependencies, including NestJS, Swagger, rxjs, nestjs-zod, Fastify, class-validator, multipart, and test packages, stay in the consuming project or an isolated matrix—not in Kontract's root package.json. Future NestJS versions and other target technologies use sibling matrices so their peer dependencies and lockfiles remain isolated.
Limits and recovery
Keep transport tests and generated output current.
The rule covers standard NestJS Get, Post, Put, Patch, and Delete routes. Configure the rule's exemptDecoratorsoption for non-standard decorators only after transport-specific tests cover them. No-content responses and file, stream, SSE, and raw transports do not use a JSON serializer. Scalar, array, union, nullable, and reference-alias JSON schemas remain JSON and still use an operation-owned response DTO. If operation metadata cannot be resolved, regenerate first, then verify the rule's aliases option and the generated directory barrel.
This check complements TypeScript, nestjs-zod, integration tests, and end-to-end tests. It does not replace status, header, envelope, error, or transport coverage. The rule requires generated named and aggregate parameter type identities; it never accepts local DTO mirrors or invents application business transformations.