Naalya Handbook

DTOs & Validation

How request and response DTOs are decorated to validate input and document the API, and what the global pipe does and does not do.

A DTO is a decorated class describing the shape of a request or response body. Not an entity, never touches the database. Every endpoint with a body takes one in and is typed by one out.

Two decorators per field

Every field carries both — a class-validator rule (enforcement, 400 on bad input) and an @nestjs/swagger decorator (OpenAPI schema, feeds the generated client).

apps/server/src/app/campus/dto/create-campus.dto.ts
@IsString()
@IsNotEmpty()
@ApiProperty()
name: string;

@IsString()
@IsOptional()
@ApiProperty({ required: false })
description?: string;
  • Required field → validator + @ApiProperty().
  • Optional field → @IsOptional() + @ApiProperty({ required: false }) (or @ApiPropertyOptional).

No @ApiProperty means no field

A validated field with no @ApiProperty never enters the schema — the generated client can't send it even though the backend accepts it.

The global pipe is bare

main.ts enables validation with a bare ValidationPipe — no options object:

apps/server/src/main.ts
app.useGlobalPipes(new ValidationPipe());

It does exactly one thing: runs each field against its class-validator decorators, throws 400 on failure. Three behaviors are off because no options were passed:

OptionStateConsequence
whitelistoffUnknown body keys are not stripped — they ride along.
forbidNonWhitelistedoffUnknown keys do not error.
transformoffNo global type coercion — values arrive as-is.

No whitelist, no transform

Design DTOs knowing the pipe only validates. Coercion is opt-in per field (below).

Coercing query params

No global transform → URL values stay strings (?limit=10"10"). Opt a field in with class-transformer's @Type, ordered before the numeric/date checks:

libs/shared/src/database/pagination/cursor-pagination.dto.ts
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@ApiPropertyOptional({ default: 10, description: 'Max results per page (1-100)' })
limit?: number;
  • @Type(() => Number) runs first, casts "10"10, then @IsInt/@Min/@Max check the number.
  • Drop @Type and @IsInt rejects the string — every paginated request 400s.
  • Dates: @Type(() => Date) before @IsDate.

@Type is per-field

A misbehaving numeric or date query param is almost always missing its @Type(() => …).

Create, Update, Response

Most resources carry three DTO shapes, each built differently:

ShapeHowFile
CreateHand-written — validators + @ApiProperty on every fieldcreate-<resource>.dto.ts
Updateextends PartialType(CreateDto) — re-emits all fields optionalupdate-<resource>.dto.ts
Responseextends DatabaseEntityDto implements <Interface><resource>.dto.ts
apps/server/src/app/campus/dto
export class UpdateCampusDto extends PartialType(CreateCampusDto) {}

export class CampusDto extends DatabaseEntityDto implements Campus {
  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  name: string;
}
  • DatabaseEntityDto supplies a documented id + timestamps — don't redeclare them.
  • implements Campus keeps the class in lockstep with the shared interface — see Entities, Tables & Types.

PartialType from @nestjs/swagger

Import PartialType from @nestjs/swagger, not @nestjs/mapped-types — only the swagger one preserves @ApiProperty metadata. OmitType / IntersectionType (also swagger) handle drop-a-field and merge.

Validating nested objects

@IsObject() only checks "has keys" — not what's inside. Two patterns:

  • Light — document the type, trust the shape loosely. Use for trusted nested data.

    apps/server/src/app/campus/dto/create-campus.dto.ts
    @IsObject()
    @IsNotEmpty()
    @ApiProperty({ type: CreateAddressDto, required: true })
    address: CreateAddressDto;
  • Strict — recurse into the nested DTO's validators. Use for untrusted data or arrays.

    apps/server/src/app/role/dto/create-role.dto.ts
    @IsArray()
    @ValidateNested({ each: true })
    @Type(() => PermissionDto)
    @ApiProperty({ type: [PermissionDto] })
    permissions: PermissionDto[];

@ValidateNested({ each: true }) runs each element through PermissionDto; @Type(() => PermissionDto) tells class-transformer which class to instantiate. The two always travel together.

What gets documented

  • Add description to @ApiProperty only when the field name isn't self-explanatory.
  • Use @ApiPropertyOptional (or required: false) for anything @IsOptional, so client and rules agree.
  • Keep controller concerns (operationId, per-status responses) off the DTO — see API Conventions.

Where to go next

On this page