Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке dto та як працює валідація в NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**DTO (Data Transfer Object)** в NestJS - це клас, що описує структуру та правила валідації вхідних даних. `ValidationPipe` перевіряє ці правила до виклику контролера і повертає 400, якщо щось не відповідає вимогам. ```typescript app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, })); ``` **Ключове:** декоратори `class-validator` перевіряються в рантаймі. TypeScript-типи самі по собі не валідують HTTP-запити.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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-валідатори автоматично.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.