open atlas
↑ К треку
NestJS с нуля до senior NEST · 02 · 03

Кастомные pipe и стадия трансформации

Pipe работает на один аргумент прямо перед хендлером и делает две работы: приводит и валидирует. Стадия трансформации — это где «42» становится 42, а сырое тело становится настоящим DTO, и где enableImplicitConversion молча превращает ?active=false в true.

NEST Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

В тикете было написано «отчёт по деактивированным пользователям показывает активных». Запрос был 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= (пустая строка) на поле numberNumber("") это 0, а ?page= с мусором вроде 12abcNumber("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`?

Вспомните перед уходом
  1. 01
    Объясни, что такое pipe, где он работает, его две работы и что даёт ArgumentMetadata.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.