Що таке 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, внутрішній виклик сервісу - звичайний інтерфейс
Швидкий приклад
// 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 CreatedValidationPipe перетворює тіло запиту на екземпляр 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, обидва є за замовчуванням.
Глобальне налаштування
// 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
// Запит: GET /users?age=25
export class GetUsersDto {
@IsInt() age: number; // помилка - "25" є рядком без transform
}
// Виправлення: встановити transform: true у ValidationPipeQuery-параметри і path-змінні приходять як рядки. Без transform: true навіть age=25 не пройде @IsInt().
2. Немає whitelist: true
// Клієнт надсилає: { email: "user@x.com", role: "admin" }
// Без whitelist: true → role: "admin" потрапляє до сервісу
// Виправлення: whitelist: true видаляє невідомі поля автоматично
// Ще надійніше: forbidNonWhitelisted: true → 400 на зайві поляЦе найпоширеніша вразливість, яку я бачив у NestJS-кодових базах. Одне зайве поле - і хтось отримує незапланований шлях до підвищення прав.
3. Вкладені DTO без @Type()
// Неправильно - items залишаються plain-масивом об'єктів
@ValidateNested({ each: true })
items: OrderItemDto[];
// Правильно
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];class-transformer потребує @Type(), щоб знати, який клас інстанціювати. Без нього валідація вкладених об'єктів не виконується взагалі, і некоректні дані проходять далі.
4. @IsOptional() на обов'язкових полях
// Неправильно - приймає 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 на власний - потрібно, коли клієнти очікують певну структуру помилки.
Приклади
Базовий: реєстрація користувача
// 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"] }. У відповіді вказано точно яке поле і чому не пройшло.
Середній: замовлення з вкладеними позиціями
// 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"] }. Шлях у повідомленні вказує точно на яку позицію масиву.
Розширений: власний декоратор валідації
// 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-валідатори автоматично.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.