Documenting Responses

Declare the status codes, body types and error shapes your handlers really return, with usable examples.

Syntax@ApiResponse({ status, description?, type?, isArray?, example? | examples? })

The request half of a spec is mostly inferred. The response half is not: Nest knows a handler returns a Promise and nothing more, so every status code and body shape has to be declared. @ApiResponse is that declaration, and the per-status shortcuts are the same function with the status pre-filled — @ApiOkResponse(o) is @ApiResponse({ ...o, status: 200 }), @ApiNotFoundResponse(o) is status 404, and so on for every member of HttpStatus.

Where the real status comes from

These decorators document; they never enforce. The actual status is decided at the end of the request lifecycle. Middleware runs first, then guards, then interceptors, then pipes, then your handler; if the handler throws, an exception filter — a class that converts a thrown exception into an HTTP response — produces the body instead, and interceptors on the way out may still reshape it. Nest answers 201 for @Post and 200 for the other methods by default, and @HttpCode(204) changes that. If your decorator and that chain disagree, the spec is simply wrong and nothing will tell you.

Errors are part of the contract

Document the failures a client must handle, and document the body it will really receive. A ValidationPipe rejection, for example, is a 400 whose message is an array of strings rather than a single one. Describe that shape with a DTO so a generated client can narrow on it.

Example

Example · typescript
// src/orders/orders.controller.ts
import {
  Body,
  Controller,
  Get,
  HttpCode,
  Param,
  ParseUUIDPipe,
  Post
} from '@nestjs/common';
import {
  ApiBadRequestResponse,
  ApiCreatedResponse,
  ApiNoContentResponse,
  ApiNotFoundResponse,
  ApiOkResponse,
  ApiOperation,
  ApiTags
} from '@nestjs/swagger';
import { CreateOrderDto } from './dto/create-order.dto';
import { ValidationErrorDto } from './dto/validation-error.dto';
import { OrderEntity } from './entities/order.entity';
import { OrdersService } from './orders.service';

@ApiTags('orders')
@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  @ApiOperation({ summary: 'Place an order', operationId: 'createOrder' })
  // Shorthand for @ApiResponse({ status: 201, ... }) — @Post answers 201.
  @ApiCreatedResponse({ description: 'Order accepted', type: OrderEntity })
  // The body ValidationPipe really sends on a rejected payload.
  @ApiBadRequestResponse({ description: 'Invalid body', type: ValidationErrorDto })
  create(@Body() dto: CreateOrderDto): Promise<OrderEntity> {
    return this.orders.create(dto);
  }

  @Get(':id')
  @ApiOkResponse({ type: OrderEntity })
  @ApiNotFoundResponse({ description: 'No order with that id' })
  findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderEntity> {
    return this.orders.findOne(id);
  }

  @Get()
  // isArray wraps the referenced schema in an array; the longhand is
  // @ApiResponse({ status: 200, schema: { type: 'array', items: { $ref: ... } } }).
  @ApiOkResponse({ type: OrderEntity, isArray: true })
  findAll(): Promise<OrderEntity[]> {
    return this.orders.findAll();
  }

  @Post(':id/cancel')
  @HttpCode(204) // without this, @Post would answer 201
  @ApiNoContentResponse({ description: 'Cancelled; no body returned' })
  cancel(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
    return this.orders.cancel(id);
  }
}

When to use it

  • Tell a mobile client which status codes an endpoint can return before they write the error handling.
  • Document the 400 body ValidationPipe produces so clients can show field-level messages.
  • Describe a paginated envelope that TypeScript generics cannot express as a schema.
  • Mark an endpoint deprecated in the spec while its replacement is rolled out.

More examples

An error DTO that matches the real body

Two different error shapes come out of a default Nest app, and clients break on the difference: code written for `message: string` throws when it meets the validation pipe's array. Declaring both DTOs and pointing the right one at each status is what lets a typed client narrow the error correctly. If you install a global exception filter that normalises errors into one envelope, document that envelope instead and delete these.

Example · typescript
// src/orders/dto/validation-error.dto.ts
import { ApiProperty } from '@nestjs/swagger';

// Mirrors what the built-in ValidationPipe throws: a BadRequestException
// whose `message` is the list of failed constraints, one string each.
export class ValidationErrorDto {
  @ApiProperty({ example: 400 })
  statusCode: number;

  @ApiProperty({
    type: [String],
    example: ['customer must be shorter than or equal to 40 characters']
  })
  message: string[];

  @ApiProperty({ example: 'Bad Request' })
  error: string;
}

// src/orders/dto/http-error.dto.ts
import { ApiProperty } from '@nestjs/swagger';

// Every other HttpException: `message` is a single string, not an array.
export class HttpErrorDto {
  @ApiProperty({ example: 404 })
  statusCode: number;

  @ApiProperty({ example: 'No order with that id' })
  message: string;
}

A generic envelope with getSchemaPath

TypeScript generics vanish at runtime, so Page<OrderEntity> cannot be inferred into a schema. The `schema` form of @ApiResponse is the way out: compose the envelope by hand and reference the item type with getSchemaPath. Factor it into a small decorator factory once more than two endpoints paginate, otherwise this block gets copied and the copies drift.

Example · typescript
// src/common/dto/page-meta.dto.ts
import { ApiProperty } from '@nestjs/swagger';

export class PageMetaDto {
  @ApiProperty()
  total: number;

  @ApiProperty()
  page: number;
}

// src/orders/orders-page.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { PageMetaDto } from '../common/dto/page-meta.dto';
import { OrderEntity } from './entities/order.entity';

// Register both schemas: the raw definition below only refers to them by $ref,
// so the scanner has no other way to learn they exist.
@ApiExtraModels(PageMetaDto, OrderEntity)
@Controller('orders')
export class OrdersPageController {
  @Get('page')
  @ApiOkResponse({
    description: 'One page of orders',
    // The `schema` form of @ApiResponse: a raw OpenAPI schema, not a class.
    schema: {
      type: 'object',
      required: ['data', 'meta'],
      properties: {
        data: {
          type: 'array',
          items: { $ref: getSchemaPath(OrderEntity) }
        },
        meta: { $ref: getSchemaPath(PageMetaDto) }
      }
    }
  })
  page(@Query('page') page = '1') {
    return { data: [], meta: { total: 0, page: Number(page) } };
  }
}

Named examples a client author can paste

An example is the part of a spec a client author actually copies, and an empty-result example is worth as much as a populated one, because that is the case callers forget to handle. @ApiOperation sets the operation's summary, description, deprecated flag and operationId; the operationId is what client generators turn into a method name, so setting it by hand pins that name against later refactors.

Example · typescript
// src/orders/orders-search.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { OrderEntity } from './entities/order.entity';

@Controller('orders')
export class OrdersSearchController {
  @Get('search')
  @ApiOperation({
    summary: 'Search orders',
    description: 'Results are capped at 100 rows.',
    deprecated: true // struck through in the UI and marked in the spec
  })
  @ApiQuery({ name: 'status', enum: ['open', 'shipped'], required: false })
  @ApiOkResponse({
    type: OrderEntity,
    isArray: true,
    // Each entry needs BOTH summary and value; the UI shows summary in the
    // dropdown. Use `example` (singular) for a bare value with no label.
    examples: {
      empty: { summary: 'No matches', value: [] },
      one: {
        summary: 'A single open order',
        value: [
          {
            id: '6f1a2c3e-0b4d-4f6a-9c1e-8d2b5a7f0c11',
            customer: 'ACME Ltd',
            createdAt: '2026-01-14T09:30:00.000Z'
          }
        ]
      }
    }
  })
  search(@Query('status') status?: string) {
    return [];
  }
}

Discussion

  • Be the first to comment on this lesson.