Skip to main content

Що таке dto та як працює валідація в NestJS?

DTO (Data Transfer Object) в NestJS - це TypeScript-клас, що описує очікувану структуру та правила валідації для вхідних даних API. ValidationPipe перевіряє ці правила автоматично, до того як запит потрапляє до контролера.

Теорія

TL;DR

  • DTO - це контрольно-пропускний пункт для твого API: тіло запиту має відповідати оголошеній структурі та правилам, інакше клієнт отримає 400
  • ValidationPipe спрацьовує до виклику контролера, перетворює JSON на typed-екземпляр класу і видаляє зайві поля
  • Завжди разом: DTO + декоратори class-validator + глобальний ValidationPipe
  • Без whitelist: true зайві поля на кшталт role: "admin" проходять до сервісу непоміченими
  • Правило вибору: публічний ендпоінт отримує DTO, внутрішній виклик сервісу - звичайний інтерфейс

Швидкий приклад

typescript
// create-user.dto.ts import { IsEmail, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @IsEmail() email: string; @IsString() @MinLength(6) password: string; } // users.controller.ts @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); // тут дані вже перевірені } // POST /users { "email": "test@example.com", "password": "short" } // → 400 { message: ['password must be longer than 6 characters'] } // POST /users { "email": "test@example.com", "password": "secret123" } // → 201 Created

ValidationPipe перетворює тіло запиту на екземпляр CreateUserDto і перевіряє кожен декоратор. Якщо щось не відповідає правилам, NestJS кидає BadRequestException ще до того, як твій код запуститься.

Як працює ValidationPipe

ValidationPipe стоїть між HTTP-шаром і контролерами. Коли приходить запит, відбуваються чотири кроки.

Перший: class-transformer перетворює plain JSON-об'єкт на справжній екземпляр класу. Якщо встановлено transform: true, рядок "25" стає числом 25. Другий: class-validator зчитує метадані декораторів через TypeScript-рефлексію і перевіряє кожне правило. Третій: при помилці NestJS повертає BadRequestException зі списком повідомлень. Четвертий: при успіху typed-об'єкт потрапляє в параметр контролера.

Для цього потрібен emitDecoratorMetadata: true у tsconfig.json і поліфіл reflect-metadata. В проєктах, створених через NestJS CLI, обидва є за замовчуванням.

Глобальне налаштування

typescript
// main.ts app.useGlobalPipes(new ValidationPipe({ whitelist: true, // видаляти поля, яких немає в DTO forbidNonWhitelisted: true, // 400, якщо надіслано зайві поля transform: true, // рядок "25" → число 25 }));

Ці три опції разом дають поведінку, яку більшість команд очікує в продакшені. Без transform: true query-параметри і сегменти URL залишаються рядками, тому @IsInt() відхилятиме age=25 з URL-адреси.

Коли використовувати

  • Публічний ендпоінт API → DTO з декораторами і ValidationPipe
  • Ендпоінт оновлення → PartialType(CreateUserDto) автоматично робить усі поля необов'язковими
  • Query-параметри → @Query() dto: GetUsersDto працює так само, як @Body()
  • Внутрішній виклик сервісу → звичайний TypeScript-інтерфейс, валідація не потрібна
  • Завантаження файлів → DTO лише для метаданих, сам файл іде через FileInterceptor

Типові помилки

1. Забули transform: true

typescript
// Запит: GET /users?age=25 export class GetUsersDto { @IsInt() age: number; // помилка - "25" є рядком без transform } // Виправлення: встановити transform: true у ValidationPipe

Query-параметри і path-змінні приходять як рядки. Без transform: true навіть age=25 не пройде @IsInt().

2. Немає whitelist: true

typescript
// Клієнт надсилає: { email: "user@x.com", role: "admin" } // Без whitelist: true → role: "admin" потрапляє до сервісу // Виправлення: whitelist: true видаляє невідомі поля автоматично // Ще надійніше: forbidNonWhitelisted: true → 400 на зайві поля

Це найпоширеніша вразливість, яку я бачив у NestJS-кодових базах. Одне зайве поле - і хтось отримує незапланований шлях до підвищення прав.

3. Вкладені DTO без @Type()

typescript
// Неправильно - items залишаються plain-масивом об'єктів @ValidateNested({ each: true }) items: OrderItemDto[]; // Правильно @ValidateNested({ each: true }) @Type(() => OrderItemDto) items: OrderItemDto[];

class-transformer потребує @Type(), щоб знати, який клас інстанціювати. Без нього валідація вкладених об'єктів не виконується взагалі, і некоректні дані проходять далі.

4. @IsOptional() на обов'язкових полях

typescript
// Неправильно - приймає email: "" (порожній рядок) @IsOptional() @IsEmail() email: string; // Якщо email обов'язковий - прибрати @IsOptional() // Якщо поле може бути відсутнім, але при наявності мусить бути валідним, // залиши, але знай: "" разом з @IsOptional() + @IsEmail() проходить

5. Відсутній emitDecoratorMetadata у tsconfig

Якщо декоратори ніби не працюють, перевір tsconfig.json. Потрібні і experimentalDecorators: true, і emitDecoratorMetadata: true. NestJS CLI додає обидва, але при ручному налаштуванні другий часто пропускають.

Де зустрічається на практиці

  • NestJS + Prisma: DTO перевіряє дані до prisma.user.create(), тому в базу не потрапляє нічого невалідного
  • NestJS + Swagger: додай @ApiProperty() до полів DTO і документація OpenAPI згенерується автоматично
  • Мікросервіси: DTO описують контракт повідомлень між сервісами в гібридних NestJS-застосунках
  • Фронтенд: OpenAPI-специфікація з DTO дозволяє генерувати TypeScript-типи для React-форм через openapi-typescript

Питання на співбесіді

Q: Що відбувається без ValidationPipe?
A: Тіло запиту потрапляє в контролер як any. TypeScript-типи стираються в рантаймі, тому анотації в DTO нічого не перевіряють. Доведеться валідувати вручну в кожному ендпоінті.

Q: Як class-validator знаходить правила декораторів під час виконання?
A: Через TypeScript-рефлексію метаданих. reflect-metadata зберігає інформацію декораторів, прив'язану до властивості класу, а class-validator зчитує її при виклику validate(). Саме тому emitDecoratorMetadata: true не є опціональним.

Q: У чому різниця між @IsDefined() і @IsNotEmpty()?
A: @IsDefined() відхиляє тільки null і undefined. @IsNotEmpty() також відхиляє порожній рядок "" і порожній масив []. Для більшості рядкових полів потрібен саме @IsNotEmpty().

Q: Чи можна так само валідувати query-параметри?
A: Так. @Query() dto: GetUsersDto з ValidationPipe працює так само, як @Body(). Тільки обов'язково встанови transform: true, бо значення query-параметрів завжди приходять як рядки.

Q (senior): Поясни групи валідації та exceptionFactory.
A: Групи валідації дозволяють застосовувати декоратори умовно. @IsUnique({ groups: ['create'] }) спрацює тільки коли передати groups: ['create'] у ValidationPipe, що зручно в багатокрокових формах. Опція exceptionFactory замінює стандартний формат відповіді 400 на власний - потрібно, коли клієнти очікують певну структуру помилки.

Приклади

Базовий: реєстрація користувача

typescript
// dto/create-user.dto.ts import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator'; export enum UserRole { USER = 'user', ADMIN = 'admin' } export class CreateUserDto { @IsString() @MinLength(2) @MaxLength(50) name: string; @IsEmail() email: string; @IsOptional() @IsEnum(UserRole) role?: UserRole = UserRole.USER; } // users.controller.ts @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); }

Валідний запит повертає 201. Запит з email: "not-an-email" отримує 400 { message: ["email must be an email"] }. У відповіді вказано точно яке поле і чому не пройшло.

Середній: замовлення з вкладеними позиціями

typescript
// dto/create-order.dto.ts import { IsUUID, IsInt, Min, IsPositive, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class OrderItemDto { @IsUUID() productId: string; @IsInt() @Min(1) quantity: number; } export class CreateOrderDto { @ValidateNested({ each: true }) @Type(() => OrderItemDto) items: OrderItemDto[]; @IsPositive() total: number; } // orders.controller.ts @Post() create(@Body() dto: CreateOrderDto) { return this.ordersService.placeOrder(dto); }

Надішли items: [{ productId: "not-a-uuid", quantity: 0 }] і отримаєш 400 { message: ["items.0.productId must be a UUID", "items.0.quantity must not be less than 1"] }. Шлях у повідомленні вказує точно на яку позицію масиву.

Розширений: власний декоратор валідації

typescript
// decorators/is-slug.decorator.ts import { registerDecorator, ValidationOptions } from 'class-validator'; export function IsSlug(options?: ValidationOptions) { return (object: object, propertyName: string) => { registerDecorator({ name: 'isSlug', target: object.constructor, propertyName, options, validator: { validate(value: unknown) { return typeof value === 'string' && /^[a-z0-9-]+$/.test(value); }, defaultMessage: () => `${propertyName} must contain only lowercase letters, numbers, and hyphens`, }, }); }; } // dto/create-post.dto.ts export class CreatePostDto { @IsString() title: string; @IsSlug() slug: string; // "my-post-title" пройде, "My Post Title" - ні }

Власні декоратори будуються за тим самим шаблоном реєстрації, що і вбудовані. Для асинхронної валідації, наприклад перевірки унікальності email в базі даних, поверни Promise<boolean> з validate(). ValidationPipe обробляє async-валідатори автоматично.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?