Documenting DTOs
How @ApiProperty turns a DTO class into a request schema, and how to keep that schema honest.
@ApiProperty(options?: ApiPropertyOptions) // @ApiPropertyOptional(o) === @ApiProperty({ ...o, required: false })A DTO (data transfer object) is a class that describes the shape of a request body or query. Nest already uses it for validation; the Swagger decorators make it describe itself in the spec as well. They are needed because TypeScript types do not survive compilation: at runtime a string property is just a key with no type attached, so something has to record the type explicitly.
How it works
@ApiProperty() stores schema metadata on the class when the file is first evaluated. Nothing reads it until SwaggerModule.createDocument runs, which is why a wrong decorator produces a wrong document rather than a runtime error. Properties are required unless you say otherwise. @ApiPropertyOptional(options) is exactly @ApiProperty({ ...options, required: false }) — the same function with one argument pre-filled. Likewise { type: [String] } and { type: String, isArray: true } produce the same array schema.
Keeping it honest
Validation and documentation are two separate sets of decorators over the same property, and nothing forces them to agree. If @MaxLength(40) says forty and @ApiProperty({ maxLength: 60 }) says sixty, clients will send 60 characters and get a 400. The CLI plugin's classValidatorShim option, on by default, closes that gap by deriving the constraints from the class-validator decorators instead. Where you write both by hand, treat the validator as the truth and the documentation as a copy that must match.
Example
// src/orders/dto/create-order.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested
} from 'class-validator';
export enum Currency {
USD = 'USD',
EUR = 'EUR'
}
export class OrderLineDto {
ApiProperty({ example: 'sku_1024' })
IsString()
sku: string;
ApiProperty({ minimum: 1, default: 1 })
IsInt()
Min(1)
quantity: number;
}
export class CreateOrderDto {
ApiProperty({ maxLength: 40, example: 'ACME Ltd' })
IsString()
MaxLength(40) // must agree with maxLength above
customer: string;
// enumName keeps the enum as one reusable schema component named 'Currency'
// instead of inlining the same union on every property that uses it.
ApiProperty({ enum: Currency, enumName: 'Currency' })
IsEnum(Currency)
currency: Currency;
// Arrays of classes need the element type spelled out; the compiler's
// emitted metadata only says "Array".
ApiProperty({ type: [OrderLineDto] })
ValidateNested({ each: true })
Type(() => OrderLineDto)
ArrayMinSize(1)
lines: OrderLineDto[];
// Shorthand for @ApiProperty({ required: false, maxLength: 200 }).
ApiPropertyOptional({ maxLength: 200 })
IsOptional()
IsString()
note?: string;
}When to use it
- Document a POST body so a client developer knows which fields are required before they call the endpoint.
- Build an UpdateXDto for PATCH without duplicating a long CreateXDto.
- Give an enum a stable component name so generated clients expose it as a named type.
- Keep an internal cost field out of both the spec and the serialised response.
More examples
Derived DTOs, short and long
PartialType, PickType, OmitType and IntersectionType build a new class at runtime and copy the stored metadata across, so the generated schema stays in step with the source DTO. Use them for PATCH bodies, where hand-writing the optional twin of a fifteen-field DTO guarantees the two drift apart. PartialType also takes a second argument, { skipNullProperties: false }, when the partial fields must reject null rather than accept it.
// src/orders/dto/update-order.dto.ts
import {
ApiPropertyOptional,
OmitType,
PartialType,
PickType
} from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { CreateOrderDto } from './create-order.dto';
// Shorthand: every property of CreateOrderDto, all optional, with both the
// validation rules and the @ApiProperty metadata carried over.
export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
// Longhand of the same idea for a single field, written out by hand.
export class UpdateOrderNoteDto {
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
note?: string;
}
// Two more helpers from the same family:
export class ReplaceOrderDto extends OmitType(CreateOrderDto, ['customer'] as const) {}
export class OrderCustomerDto extends PickType(CreateOrderDto, ['customer'] as const) {}Naming schemas and hiding fields
Reach for @ApiSchema when the class name is an implementation detail: renaming OrderEntity to Order in the code would otherwise rename a type in every generated client. @ApiHideProperty and @Exclude are worth learning as a pair, because hiding a field from the document without excluding it from serialisation is how internal values end up in responses the spec claims do not contain them.
// src/orders/entities/order.entity.ts
import { ApiHideProperty, ApiProperty, ApiSchema } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
// Without @ApiSchema the component is named after the class: 'OrderEntity'.
// Clients generated from the spec use that name as a type name, so pin it.
@ApiSchema({ name: 'Order', description: 'A placed order' })
export class OrderEntity {
@ApiProperty({ format: 'uuid' })
id: string;
@ApiProperty({ example: 'ACME Ltd' })
customer: string;
@ApiProperty({ type: String, format: 'date-time' })
createdAt: Date;
// Kept out of the document AND out of the response: @ApiHideProperty only
// affects the spec, @Exclude is what ClassSerializerInterceptor acts on.
@ApiHideProperty()
@Exclude()
internalCostCents: number;
}Schemas the decorators cannot infer
@ApiProperty accepts raw schema keys, which is the escape hatch for anything the type system cannot communicate to it: tuples, nested arrays, oneOf and allOf unions. getSchemaPath(Model) returns the '#/components/schemas/Model' string so you do not hand-write refs that a typo can break. Reach for this when a field is genuinely polymorphic; for everything else the inferred schema is easier to keep correct.
// src/reports/dto/report-query.dto.ts
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
export class GeoFilterDto {
@ApiProperty()
country: string;
}
export class TagFilterDto {
@ApiProperty({ type: [String] })
tags: string[];
}
// Both classes are only referenced from the raw schema below, so the scanner
// would never see them. @ApiExtraModels registers them as components.
@ApiExtraModels(GeoFilterDto, TagFilterDto)
export class ReportQueryDto {
// A raw JSON Schema fragment, passed through untouched.
@ApiProperty({
type: 'array',
items: { type: 'array', items: { type: 'number' } }
})
boundingBox: number[][];
// oneOf is not something @ApiProperty can infer from a TypeScript union.
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(GeoFilterDto) },
{ $ref: getSchemaPath(TagFilterDto) }
]
})
filter: GeoFilterDto | TagFilterDto;
}
Discussion