Documenting DTOs

How @ApiProperty turns a DTO class into a request schema, and how to keep that schema honest.

Syntax@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

Example · typescript
// 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.

Example · typescript
// 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.

Example · typescript
// 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.