DTO, class-validator и pipes
DTO — это runtime-класс, чьи декораторы class-validator описывают валидный запрос; ValidationPipe читает эти метаданные до хендлера, отклоняет плохой ввод с 400, срезает неизвестные поля и приводит типы — так хендлер доверяет своим аргументам.
POST на /users приходит с { "email": 42, "isAdmin": true }. Валидации нет, поэтому контроллер берёт тело как есть, сервис разворачивает его в Prisma create, и вот у тебя пользователь, чей email — число 42 и который тихо выдал себе админку — поле, которое твой API вообще не собирался показывать. Хендлер доверился проводу. Этого не должно было случиться: контракт полагалось проверить у двери, до того как отработает хоть строка бизнес-логики.
DTO — это класс, а не интерфейс, потому что метаданные должны дожить до runtime
DTO (Data Transfer Object) описывает форму тела запроса, query или param. В Nest ты пишешь его как класс, никогда не как TypeScript interface. Причина конкретна: интерфейсы стираются при компиляции — в runtime их не существует, — а валидация Nest работает через чтение метаданных декораторов с объекта в runtime. Декоратору @IsEmail() на свойстве интерфейса не к чему привязаться после того, как типы стёрты. Класс переживает транспиляцию как настоящее значение, поэтому декораторы (и эмитнутые метаданные типов) на месте, когда pipe их спрашивает.
Каждое поле ты аннотируешь декораторами class-validator, которые объявляют его ограничения: @IsString, @IsEmail, @IsInt, @Min, @IsOptional, @ValidateNested и десятки других. DTO становится единым декларативным источником истины «как выглядит валидный запрос» — читаемым, переиспользуемым и тестируемым в изоляции.
import { IsEmail, IsString, IsInt, Min, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@Min(2) // длина строк — через @MinLength в реальном коде; @Min показан для чисел ниже
name: string;
@IsInt()
@Min(18)
age: number;
@IsOptional()
@IsString()
bio?: string;
}Pipe бежит до хендлера — и решает, что хендлер вообще увидит
Pipe — это класс с методом transform(), который Nest запускает на аргументе маршрута до того, как хендлер его получит. Pipes делают две работы: трансформацию (изменить форму или привести значение) и валидацию (проверить, бросить исключение при провале). Встроенный ValidationPipe — тот, что связывает DTO воедино: он читает метаданные class-validator с твоего DTO, валидирует входящий payload против них и при провале бросает BadRequestException — автоматический 400 со списком того, что не так. Хендлер запускается только на вводе, который уже прошёл.
Включаешь его один раз, глобально, в main.ts:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // срезать свойства, которых нет в DTO
forbidNonWhitelisted: true, // ...или отклонить запрос, если они появились
transform: true, // привести plain-объект к экземпляру DTO + привести примитивы
}),
);
await app.listen(3000);
}
bootstrap();Контроллер тогда просто объявляет DTO как тип аргумента @Body(). Кода валидации в контроллере нет — в этом и смысл:
import { Controller, Post, Body, Get, Param, ParseIntPipe } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
@Post()
create(@Body() dto: CreateUserDto) {
// dto уже провалидирован И является экземпляром CreateUserDto.
// Неизвестные поля вроде `isAdmin` срезаны (или запрос отклонён).
return this.users.create(dto);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// ParseIntPipe — встроенный pipe — приводит строковый param к числу
// и отдаёт 400, если это не валидное целое. Здесь id — настоящее число.
return this.users.findOne(id);
}
}Три опции, которые важны: whitelist, forbidNonWhitelisted, transform
Эти опции не косметика — две из них настоящий контроль безопасности.
whitelist: trueсрезает любое свойство payload’а, у которого в DTO нет декоратора валидации. ЛишнийisAdmin: trueиз хука просто никогда не доходит до твоего сервиса.forbidNonWhitelisted: true(используй вместе сwhitelist) идёт дальше: вместо тихого срезания неизвестных полей он отклоняет весь запрос с 400, перечисляя их. Это более жёсткая позиция — она вскрывает клиентов, шлющих поля, которые ты не принимаешь, а не тихо их роняет.transform: trueделает две вещи. Он создаёт экземпляр класса DTO из plain JSON-объекта (через class-transformer), так что значение, которое получает хендлер, — настоящий экземплярCreateUserDto, а не голый объект; методы иinstanceofработают. Он также приводит примитивные типы: query-param?age=18, пришедший строкой"18", становится числом18, когда поле DTO типизировано какnumber.
Вместе whitelist + forbidNonWhitelisted — твоя защита от mass-assignment / over-posting — класса багов, где клиент задаёт поле (role, balance, isAdmin), которое сервер никогда не предполагал делать задаваемым с клиента. Валидация на границе — это то, что позволяет остальному приложению доверять своим вводам: как только pipe отработал, каждый слой ниже может считать DTO заведомо корректным.
▸Почему это работает
Почему глобальный pipe, а не по-маршрутно? Регистрация ValidationPipe один раз в main.ts делает валидацию дефолтом для каждого эндпоинта, поэтому новый контроллер безопасен по умолчанию — ты не можешь забыть её добавить. Цена в том, что whitelist/forbid теперь применяются везде, — а именно этого ты и хочешь для публичного API. Для редкого эндпоинта с другими правилами переопределяешь локально через @UsePipes(new ValidationPipe({ ... })) на этом хендлере. Берегись одной ловушки: transform: true надёжно приводит примитивы только при наличии метаданных типов, поэтому в tsconfig должны быть включены emitDecoratorMetadata и experimentalDecorators.
Схема ниже — то, через что теперь проходит каждый запрос: pipe стоит между проводом и твоим хендлером.
Таблица делает разницу наглядной — тот же шумный payload, с pipe и без него:
| Аспект | Без ValidationPipe | ValidationPipe (whitelist + forbid + transform) |
|---|---|---|
| Что доходит до хендлера | Сырой распарсенный JSON, как есть | Провалидированный экземпляр CreateUserDto |
Неизвестное поле isAdmin | Проходит прямо в сервис | Срезано (whitelist) или запрос отклонён (forbid) |
Приведение типа ?age=18 | Остаётся строкой “18” | Приведено к числу 18 |
Невалидное тело (email: 42) | Сохранено как есть; битые данные ниже по стеку | Автоматический 400 со списком ошибок по полям |
Помимо ValidationPipe, Nest поставляет маленькие встроенные pipes для одиночных скалярных аргументов — ParseIntPipe, ParseUUIDPipe, ParseBoolPipe, ParseArrayPipe, — и ты можешь написать кастомный pipe, реализовав transform(value, metadata) из PipeTransform. Но для тел запросов правило держится: клади ограничения в DTO, а не в контроллер, и пусть один глобальный ValidationPipe их обеспечивает.
Почему Nest-DTO должен быть классом, а не TypeScript-интерфейсом?
Клиент шлёт POST { email, name, isAdmin: true }, но в DTO нет поля isAdmin. ValidationPipe работает с whitelist: true. Что произойдёт?
Публичный POST-эндпоинт не должен давать клиенту задать поле, которое сервер не предполагал (mass-assignment), а плохие тела должны падать громко. Выбери конфигурацию ValidationPipe.
- 01Пройди по тому, что ValidationPipe делает с телом запроса, и где он стоит относительно хендлера.
- 02Почему whitelist + forbidNonWhitelisted важны для безопасности и почему DTO должен быть классом?
Валидируй ввод на границе, чтобы остальное приложение могло ему доверять. DTO (Data Transfer Object — объект передачи данных) — это runtime-класс, никогда не интерфейс, потому что декораторы class-validator и метаданные типов, которые читает валидатор, должны пережить компиляцию, а интерфейсы стираются. Каждое поле ты аннотируешь (@IsEmail, @IsInt, @Min, @IsOptional, @ValidateNested), чтобы декларативно объявить валидный запрос. Pipe (конвейерный преобразователь аргументов) бежит до хендлера, чтобы трансформировать и валидировать аргумент; встроенный ValidationPipe, включённый один раз через app.useGlobalPipes(new ValidationPipe({ ... })), читает метаданные DTO, отклоняет плохой ввод автоматическим 400 со списком ошибок по полям, а иначе отдаёт хендлеру чистый ввод. Его три несущие опции: whitelist срезает недекорированные свойства, forbidNonWhitelisted отклоняет запросы, которые их несут (вместе закрывая дыру mass-assignment), а transform создаёт экземпляр класса DTO и приводит примитивы вроде "18" к 18. Помимо него, встроенные pipes (ParseIntPipe и друзья) и кастомные классы PipeTransform обрабатывают скалярные аргументы. Дисциплина, что связывает это воедино: держи ограничения в DTO, а не в контроллере, и пусть один глобальный pipe обеспечивает их везде. Теперь, когда ты видишь метод контроллера, который открывается ручными проверками типов или доверяет сырому req.body, — ты знаешь, что взять вместо этого.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.