CLIKontract

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.

sumr.yamlyaml
kontract:
  sources:
    - name: api
      input: schema
      targets:
        - name: backend
          output: backend/src/shared/models
          generator:
            type: typescript-nestjs
            zod: true
            dto: zod
            operationContract: true
terminal
sumr kontract validate# check the schema sourcesumr kontract generate --target backend# refresh generated operations
recipes.controller.tsts
import { 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.

recipes.controller.tsts
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);
  }
}
clubs.controller.tsts
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.

openapi.yamlyaml
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: string

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

terminal
bun add -d @sumrco/cli eslint @typescript-eslint/parser# install development tooling
eslint.config.jsjs
import 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.

sumr.yamlyaml
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: false

envelope 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: true

Framework-neutral Zod schemas and inferred request/response types.

dto: true

NestJS DTO constructors: none, zod, class-validator, or both.

operationContract: true

Exact operation descriptors. Requires a non-none DTO mode for runtime binding.

foundation: true

One target-wide shared boundary for DI-preserving validation, response, error, security, app, Swagger, and tests, plus per-service Swagger facades.

controllers: true

Thin generated transport adapters, an application-owned port, and callback sender output when declared.

errors: true

Typed exceptions for documented non-success JSON responses.

mappers: true

Explicit one-to-one structural projections without inferred business transformations.

tests: true

Production-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: true

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