Кастомные pipe и стадия трансформации
Pipe работает на один аргумент прямо перед хендлером и делает две работы: приводит и валидирует. Стадия трансформации — это где «42» становится 42, а сырое тело становится настоящим DTO, и где enableImplicitConversion молча превращает ?active=false в true.
В тикете было написано «отчёт по деактивированным пользователям показывает активных». Запрос был GET /users?active=false. Хендлер читал query.active — настоящий boolean, как обещал DTO, — и фильтровал where: { isActive: query.active }. Читаешь код — всё верно. Фильтр говорит false, отчёт должен показывать деактивированные аккаунты. А он показывал активные. Полгода назад кто-то включил enableImplicitConversion: true, чтобы «перестать писать @Type повсюду», и этот один флаг заставил class-transformer привести строку "false" к boolean по-джаваскриптовому: Boolean("false") — непустая строка — это true. Поле DTO было типа boolean, валидатор прошёл, а значение было ложью. Этот урок — про стадию трансформации: про то, где фреймворк молча меняет твои значения ещё до того, как их увидит хендлер, и где один флаг удобства выворачивает фильтр наизнанку.
Что такое pipe на самом деле
Pipe — это класс, реализующий PipeTransform<T, R>: один метод, transform(value: T, metadata: ArgumentMetadata): R. Nest запускает его на одном аргументе хендлера, прямо перед тем, как хендлер выполнится, — после guards и pre-фазы интерсепторов, но это последнее, что трогает аргумент на входе. У pipe ровно две работы: трансформация (вернуть приведённое значение, например "42" → 42) и валидация (выбросить, чтобы отклонить запрос, что Nest превратит в 400). Он либо возвращает (возможно, преобразованное) значение, либо выбрасывает; третьего исхода нет.
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
// A hand-written equivalent of ParseIntPipe: this is all a built-in pipe is.
@Injectable()
export class ParseIntPipeManual implements PipeTransform<string, number> {
transform(value: string, _metadata: ArgumentMetadata): number {
const n = Number(value);
if (!Number.isInteger(n)) {
throw new BadRequestException(`Validation failed: "${value}" is not an integer`);
}
return n; // the handler now receives a number, not a string
}
}Второй аргумент, metadata: ArgumentMetadata, — это то, что делает pipe больше, чем тупым конвертером. Он несёт { type, metatype, data }:
type— откуда пришёл этот аргумент:'body','query','param'или'custom'.metatype— объявленный класс аргумента (CreateUserDto,Number,String…). Именно такValidationPipeузнаёт, против какого DTO валидировать — он читает metatype с аннотации типа параметра черезreflect-metadata.data— строка, переданная в декоратор, например@Param('id')→data === 'id'.
Встроенные — ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, ParseArrayPipe, DefaultValuePipe — не более чем реализации PipeTransform, которые парсят и выбрасывают BadRequestException при провале. Ты привязываешь их прямо у параметра:
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id is a number here, or the request already 400'd before we got called
return this.users.findOne(id);
}Стадия трансформации: transform:true делает настоящий DTO
По умолчанию ValidationPipe валидирует входящий объект и оставляет его обычным объектом. Параметр типизирован как CreateUserDto, но в рантайме это просто {} со строковыми полями — body instanceof CreateUserDto равно false, любые геттеры или методы DTO не существуют, а поле, объявленное Date, всё ещё string. Аннотация типа — это вымысел времени компиляции, который рантайм никогда не уважал.
Установка { transform: true } это меняет. Pipe запускает plainToInstance(metatype, value) из class-transformer и отдаёт хендлеру настоящий экземпляр класса DTO:
import { ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(
new ValidationPipe({
transform: true, // plainToInstance(CreateUserDto, body) -> a real DTO instance
whitelist: true,
}),
);export class CreateOrderDto {
@Type(() => Number) // tells class-transformer to coerce this field to a number
quantity: number;
@Type(() => ShippingDto) // nested conversion: the child is built as a ShippingDto too
shipping: ShippingDto;
get total(): number {
return this.quantity * UNIT_PRICE; // only callable because it's a REAL instance
}
}Только под transform: true работают две вещи: instanceof теперь истинно (так что значение — подлинный CreateOrderDto, а его геттеры/методы вроде total вызываемы), и декораторы @Type() отрабатывают — что и приводит quantity к number и пересобирает вложенный shipping как настоящий ShippingDto. Без transform: true @Type() бездействует, и твои вложенные объекты остаются обычными.
Footgun enableImplicitConversion — скрытая ловушка удобства
Писать @Type(() => Number) на каждом числовом поле утомительно, поэтому class-transformer предлагает срезку: transformOptions: { enableImplicitConversion: true }. С ней class-transformer приводит примитивы по объявленному TypeScript-типу каждого поля — @Type() не нужен. Поле типа number получает Number(value); поле типа boolean прогоняется через приведение к булеву; и так далее, по всему приложению, из одного флага.
new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true }, // one flag, changes coercion everywhere
});
export class UserFilterDto {
active: boolean; // no @Type needed now... but watch what "false" becomes
page: number;
}Вот ловушка, в трёх конкретных случаях:
?active=falseна полеboolean→ class-transformer приводит строку"false", а непустая строка истинна, так что ты получаешьtrue. Твой фильтр «показать деактивированных пользователей» теперь показывает активных. Это и есть тот production-инцидент дословно.?page=(пустая строка) на полеnumber→Number("")это0, а?page=с мусором вроде12abc→Number("12abc")этоNaN, тихо переданный в твой запрос.?ids=на поле-массив → приведение угадывает, а «угадай» — ровно то, чего ты не хочешь на границе валидации.
▸Почему это работает
Почему ?active=false становится true? Implicit conversion не парсит значение строки — он приводит по объявленному типу поля, используя собственные правила JavaScript. Для цели boolean на непустой строке это истинность, а "false" — непустая строка, так что Boolean("false") это true. Тот же флаг, который услужливо превращает "42" в 42 (потому что Number("42") случайно верен), превращает "false" в true (потому что приведение к булеву не имеет ничего общего со словом «false»). Удобство и баг — это один и тот же механизм: приведение-по-типу, без разбора значения. Вот почему сеньорский ход — это @Transform(({ value }) => value === 'true') или @IsBoolean() после приведения — что-то, что реально смотрит на значение.
Сеньорская позиция: предпочитай явное, по-полевое приведение — @Type(() => Number) для числовых, @Transform(...) для случаев вроде булевых, где важно значение, — а не сплошной enableImplicitConversion. Если ты всё же включаешь implicit conversion, сопровождай каждое приводимое поле валидатором (@IsBoolean(), @IsInt()), чтобы результат приведения проверялся, а не принимался на веру. Флаг меняет несколько декораторов на поверхность тихого приведения по всему приложению; это плохой обмен на границе валидации.
Кастомный pipe: альтернатива на Zod
Кастомный pipe позволяет заменить всю машинерию class-validator. Здесь Zod-схема — единственный источник истины: она выводит TypeScript-тип, и валидирует, и трансформирует, в одном safeParse:
import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodSchema) {}
transform(value: unknown, _metadata: ArgumentMetadata) {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException(result.error.format()); // reject -> 400
}
return result.data; // parsed AND coerced: one source of truth, no @Type, no reflect-metadata
}
}
// usage: @Body(new ZodValidationPipe(createUserSchema)) — the schema infers the body typeКомпромисс реален и идёт в обе стороны. Class-validator DI-нативен, идиоматичен для Nest, а его декораторы заодно служат источником для API-доков @nestjs/swagger — один набор декораторов питает и валидацию, и OpenAPI-схему. Zod даёт выведенные типы (нет дублирования декораторов/reflect-metadata) и куда лучшую композируемость (.refine, .transform, юнионы), но pipe ты проводишь сам и теряешь декораторную интеграцию со Swagger. Выбирай по ограничению: Swagger-first и тяжёлый DI → class-validator; вывод-типов-first и композируемость → Zod-pipe.
Тебе нужно валидировать И приводить сложное вложенное тело запроса (числа, булевы, вложенный объект адреса) на одном эндпоинте. Какой подход устоит?
Где в жизненном цикле запроса работает pipe и что несёт его ArgumentMetadata?
С ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } }) и полем DTO `active: boolean`, что даст запрос `?active=false`?
- 01Объясни, что такое pipe, где он работает, его две работы и что даёт ArgumentMetadata.
- 02Сопоставь transform:true с enableImplicitConversion и скажи, почему ?active=false становится true — затем дай сеньорский фикс.
Pipe — это класс, реализующий PipeTransform<T, R> — один метод transform(value, metadata), который Nest запускает на одном аргументе хендлера прямо перед хендлером (после guards), с двумя работами: привести значение или выбросить, чтобы отклонить (400). Встроенные Parse*-pipe — это ровно оно. ArgumentMetadata несёт type (‘body’|‘query’|‘param’|‘custom’), metatype (объявленный класс аргумента, через который ValidationPipe выбирает DTO) и data (строку декоратора). По умолчанию ValidationPipe оставляет тело обычным объектом, чей объявленный класс — вымысел: instanceof равно false, а поля Date/number остаются строками; установка transform: true запускает plainToInstance, так что хендлер получает настоящий экземпляр DTO с работающими геттерами и приведением @Type(), включая вложенные объекты. enableImplicitConversion — опасная срезка: один флаг заставляет class-transformer приводить примитивы по объявленному типу правилами JS без @Type, так что ?active=false на булевом становится Boolean(‘false’) === true и инвертирует фильтр, ?page= становится 0/NaN, всё молча и по всему приложению. Сеньорская позиция — явное по-полевое приведение (@Type для чисел, @Transform для осознающих значение булевых) либо валидация результата приведения через @IsBoolean()/@IsInt(), если implicit включён. Кастомный pipe вроде ZodValidationPipe — альтернатива всему стеку class-validator: Zod-схема выводит TS-тип и парсит+приводит в одном safeParse, меняя декораторный Swagger и DI-нативные repo на выведенные типы и композируемость — выбирай по ограничению. Теперь, когда булевой query-параметр ведёт себя противоположно ожидаемому, первый твой вопрос — включён ли enableImplicitConversion и есть ли @Transform, распознающий буквальное слово «false».
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.