Pipes: концепція трансформації та валідації
Pipes: концепція трансформації та валідації
🎯 Мета лекції
- Зрозуміти роль Pipes як спеціалізованого компонента для валідації та трансформації вхідних даних
- Опанувати інтерфейс
PipeTransform<T, R>та методtransform()як основу для створення власних pipes - Навчитися застосовувати pipes на різних рівнях: параметр, метод, контролер, глобально
- Засвоїти дві основні функції pipes: трансформація типів та валідація структури даних
- Вивчити механізм обробки помилок у pipes через викидання виключень
- Розуміти порядок виконання множинних pipes та їхню композицію
🔑 Ключові терміни
- Pipe (конвеєрний елемент): компонент NestJS, що виконує трансформацію або валідацію аргументів методу обробника
- PipeTransform Interface (інтерфейс трансформації): контракт, що визначає метод
transform()для обробки значення - Transformation (трансформація): перетворення вхідного значення з одного типу в інший (наприклад, string → number)
- Validation (валідація): перевірка відповідності даних певним правилам із викиданням виключення при невідповідності
- Argument Metadata (метадані аргументу): інформація про параметр методу (тип, назва декоратора, позиція)
- Global Pipes (глобальні pipes): pipes, застосовані до всіх маршрутів застосунку
Що таке Pipe: спеціалізований трансформатор даних
Pipes є одним з п'яти фундаментальних компонентів Request Pipeline у NestJS і відповідають за трансформацію та валідацію вхідних даних безпосередньо перед їх передачею в метод обробника контролера. На відміну від Middleware, що працює з сирими об'єктами Request та Response, та Guards, що приймають рішення про авторизацію, Pipes фокусуються на перетворенні аргументів методу у потрібний формат та перевірці їхньої коректності.
Концептуально Pipe можна порівняти з фільтром на виробничій лінії: він отримує сирий матеріал (рядки з URL, JSON-тіло запиту), обробляє його згідно зі специфікацією та передає далі у рафінованому вигляді. Якщо матеріал не відповідає стандартам, Pipe зупиняє конвеєр, викидаючи виключення.
Ключова відмінність Pipes від інших компонентів полягає в тому, що вони працюють на рівні параметрів методу (method parameter level). Коли ви пишете:
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
ParseIntPipe виконується тільки для параметра id, а не для всього запиту. Це забезпечує високу гранулярність та можливість застосовувати різні pipe для різних параметрів одного методу.
| (cat file.txt | grep "error" | wc -l), NestJS pipes обробляють дані послідовно, де кожен pipe може трансформувати або відкинути вхідне значення.Дві основні функції Pipes
Pipes у NestJS виконують дві чітко розмежовані, але взаємодоповнювальні функції:
Функція 1: Трансформація даних (Transformation)
Трансформація полягає у зміні типу або формату вхідного значення. HTTP-запити передають дані у вигляді рядків (параметри URL, query string) або JSON (тіло запиту). Проте TypeScript-сигнатури методів очікують конкретні типи: number, boolean, Date, класи DTO. Pipes виконують перетворення з рядкового представлення у типізоване значення.
@Get(':id')
findOne(@Param('id') id: string) {
// id = "123" (string!)
// typeof id === "string"
return this.usersService.findOne(parseInt(id, 10));
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id = 123 (number!)
// typeof id === "number"
return this.usersService.findOne(id);
}
Трансформація гарантує, що runtime-тип відповідає compile-time типу, усуваючи невідповідність між TypeScript-анотаціями та реальними даними.
Функція 2: Валідація даних (Validation)
Валідація перевіряє, чи відповідають дані встановленим правилам. На відміну від трансформації, валідація не змінює значення, а лише визначає його коректність. Якщо дані невалідні, Pipe викидає виключення, зупиняючи обробку запиту.
Валідація може включати:
- Перевірку формату: чи є рядок валідним UUID, email, URL
- Діапазонні обмеження: чи знаходиться число в межах
[0, 100] - Структурну валідацію: чи містить об'єкт всі обов'язкові поля з правильними типами
- Бізнес-правила: чи відповідає значення доменним обмеженням (наприклад, вік >= 18)
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ValidateAgePipe implements PipeTransform<number, number> {
transform(value: number): number {
if (value < 18) {
throw new BadRequestException('Age must be at least 18');
}
if (value > 120) {
throw new BadRequestException('Age must be less than 120');
}
return value; // Значення не змінюється, лише перевіряється
}
}
ParseIntPipe трансформує рядок у число, а потім перевіряє, чи не є результат NaN.Інтерфейс PipeTransform: контракт для всіх Pipes
Всі Pipes у NestJS реалізують інтерфейс PipeTransform<T, R>, де T — тип вхідного значення, а R — тип повернутого значення. Інтерфейс визначає єдиний метод transform():
import { PipeTransform, ArgumentMetadata } from '@nestjs/common';
interface PipeTransform<T = any, R = any> {
transform(value: T, metadata: ArgumentMetadata): R | Promise<R>;
}
Параметри методу transform()
Метод transform() отримує два аргументи:
value: T— вхідне значення, що потребує обробки (наприклад, рядок"123"з параметра URL)metadata: ArgumentMetadata— об'єкт з метаданими про параметр методу
Структура ArgumentMetadata:
interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<unknown>;
data?: string;
}
type: звідки прийшло значення (@Body(),@Query(),@Param(), кастомний декоратор)metatype: TypeScript-тип параметра (наприклад,String,Number,CreateUserDto)data: ім'я властивості, якщо декоратор має аргумент (наприклад,'id'для@Param('id'))
Приклад реалізації власного Pipe
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
console.log('Metadata:', metadata);
// { type: 'param', metatype: Number, data: 'id' }
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException(`Validation failed: "${value}" is not a valid integer`);
}
if (val <= 0) {
throw new BadRequestException(`Validation failed: value must be positive, received ${val}`);
}
return val;
}
}
Використання:
@Get(':id')
findOne(@Param('id', ParsePositiveIntPipe) id: number) {
// id гарантовано є додатнім числом
return this.usersService.findOne(id);
}
transform() може бути асинхронним і повертати Promise<R>. Це дозволяє виконувати асинхронні операції всередині Pipe, наприклад, перевірку існування ресурсу в базі даних перед передачею його ID в обробник.Позиція Pipes у Request Pipeline
Pipes виконуються на п'ятому етапі конвеєра обробки запиту, безпосередньо перед викликом методу обробника:
Це означає, що на момент виконання Pipes:
- Middleware вже виконано: запит розпарсено, CORS-заголовки встановлено, контекст підготовлено
- Guards вже перевірили авторизацію: користувач автентифікований, права доступу підтверджено
- Interceptors (before) вже виконалися: час початку залоговано, кеш перевірено
- Обробник ще не викликано: метод контролера чекає на валідовані аргументи
Якщо Pipe викидає виключення, обробник не викликається, і управління відразу передається в Exception Filters.
Request. Вони отримують лише конкретне значення параметра (value) та його метадані. Якщо потрібен доступ до заголовків або контексту запиту, використовуйте Guards або Middleware.Рівні застосування Pipes
Pipes можна застосовувати на чотирьох рівнях гранулярності, що забезпечує гнучкість у проєктуванні валідації:
Рівень 1: Pipe на параметрі методу (найбільш специфічний)
Pipe застосовується лише до конкретного параметра конкретного методу:
@Get(':id')
findOne(
@Param('id', ParseIntPipe) id: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
) {
// ParseIntPipe застосовано до id
// DefaultValuePipe та ParseIntPipe застосовано до limit у ланцюгу
return this.usersService.findMany(id, limit);
}
Це найбільш точний рівень, що дозволяє мати різні pipes для різних параметрів.
Рівень 2: Pipe на методі контролера
Pipe застосовується до всіх параметрів методу:
@Post()
@UsePipes(new ValidationPipe({ whitelist: true }))
create(@Body() dto: CreateUserDto, @Query('notify') notify: string) {
// ValidationPipe застосовано до обох параметрів: dto та notify
return this.usersService.create(dto, notify === 'true');
}
Це зручно, коли всі параметри методу потребують однакової обробки.
Рівень 3: Pipe на рівні контролера
Pipe застосовується до всіх методів контролера:
@Controller('users')
@UsePipes(ValidationPipe)
export class UsersController {
@Get()
findAll(@Query() query: FindAllDto) {
// ValidationPipe застосовано
}
@Post()
create(@Body() dto: CreateUserDto) {
// ValidationPipe застосовано
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
// ValidationPipe застосовано до обох параметрів
}
}
Це дозволяє встановити єдину політику валідації для всього ресурсу.
Рівень 4: Глобальний Pipe (найменш специфічний)
Pipe застосовується до всіх маршрутів усього застосунку:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Глобальний Pipe для всього застосунку
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.listen(3000);
}
bootstrap();
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
@Module({
providers: [
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
],
})
export class AppModule {}
APP_PIPE провайдер, якщо Pipe має залежності (наприклад, впроваджені через конструктор сервіси). Pipes, зареєстровані через app.useGlobalPipes(), створюються поза контекстом модуля і не можуть використовувати Dependency Injection.Порядок виконання множинних Pipes
Якщо до одного параметра застосовано кілька Pipes (наприклад, на рівні параметра, методу та глобально), вони виконуються у порядку від загального до конкретного:
- Глобальні Pipes (зареєстровані в
main.tsабо черезAPP_PIPE) - Pipes рівня контролера (через
@UsePipes()на класі) - Pipes рівня методу (через
@UsePipes()на методі) - Pipes рівня параметра (через другий аргумент декоратора
@Param(),@Body()тощо)
Кожен наступний Pipe отримує результат попереднього:
@Controller('users')
@UsePipes(TrimStringsPipe) // Виконається 2-м
export class UsersController {
@Post()
@UsePipes(SanitizeHtmlPipe) // Виконається 3-м
create(
@Body(new ValidateEmailPipe()) dto: CreateUserDto // Виконається 4-м
) {
// dto пройшов через 3 pipes: Trim → Sanitize → ValidateEmail
}
}
Викидання виключень у Pipes
Якщо Pipe виявляє невалідні дані, він має викинути виключення для припинення обробки запиту. NestJS надає вбудовані класи виключень для різних HTTP-статусів:
import {
PipeTransform,
Injectable,
BadRequestException,
NotFoundException
} from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
// 400 Bad Request
throw new BadRequestException(
`Validation failed: "${value}" is not a valid integer`
);
}
return val;
}
}
@Injectable()
export class UserExistsPipe implements PipeTransform {
constructor(private usersService: UsersService) {}
async transform(userId: string) {
const user = await this.usersService.findById(userId);
if (!user) {
// 404 Not Found
throw new NotFoundException(`User with ID "${userId}" does not exist`);
}
return user; // Повертаємо об'єкт замість ID
}
}
Виключення автоматично перехоплюється Exception Filter та перетворюється на HTTP-відповідь:
{
"statusCode": 400,
"message": "Validation failed: \"abc\" is not a valid integer",
"error": "Bad Request"
}
HttpException. Найбільш поширені:BadRequestException(400) — невалідні вхідні даніUnauthorizedException(401) — відсутня або невалідна автентифікаціяForbiddenException(403) — відмова в доступіNotFoundException(404) — ресурс не знайдено
Композиція Pipes: ланцюгова обробка даних
Потужна можливість Pipes полягає в тому, що їх можна комбінувати для послідовної обробки. Наприклад, спочатку встановити значення за замовчуванням, потім перетворити у число, потім перевірити діапазон:
@Get()
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe, new MaxValuePipe(100)) limit: number
) {
// Якщо page відсутній → DefaultValuePipe встановить 1
// Потім ParseIntPipe перетворить на number
// Аналогічно для limit + MaxValuePipe обмежить до 100
return this.usersService.findAll({ page, limit });
}
Реалізація MaxValuePipe:
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class MaxValuePipe implements PipeTransform<number, number> {
constructor(private readonly max: number) {}
transform(value: number): number {
if (value > this.max) {
throw new BadRequestException(`Value must not exceed ${this.max}, received: ${value}`);
}
return value;
}
}
Такий підхід дозволяє створювати переконфігуровані pipes, що можна комбінувати як будівельні блоки.
class-validator, яка надає декларативний підхід через декоратори на DTO-класах. Це буде детально розглянуто в наступних лекціях.Коли використовувати Pipes замість інших компонентів
Розуміння правильного місця для Pipes у архітектурі є критичним для підтримуваного коду:
✅ Використовуйте Pipes для:
- Трансформації типів параметрів: string → number, string → boolean, string → Date
- Валідації структури DTO: перевірка обов'язкових полів, типів, форматів
- Парсингу складних типів: масиви, enum, UUID, JSON
- Встановлення значень за замовчуванням: коли параметр опційний
- Санітизації вхідних даних: обрізання пробілів, видалення HTML-тегів
- Перевірки існування ресурсів: завантаження об'єкта з БД за ID перед обробником
❌ Не використовуйте Pipes для:
- Авторизації: перевірка прав доступу належить Guards
- Автентифікації: перевірка токенів належить Middleware або Guards
- Логування: це завдання Interceptors або Middleware
- Трансформації відповідей: використовуйте Interceptors на фазі "after"
- Складної бізнес-логіки: вона належить сервісам
Так, метод transform() може повертати Promise<R>, що дозволяє виконувати асинхронні операції, наприклад, запити до бази даних. Це корисно для Pipes, що перевіряють існування ресурсів:
@Injectable()
export class UserByIdPipe implements PipeTransform {
constructor(private usersService: UsersService) {}
async transform(id: string) {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user; // Обробник отримає об'єкт User замість ID
}
}
User, але параметр має тип string, TypeScript покаже помилку під час компіляції, але код все одно виконається. Проте це порушує контракт та може призвести до runtime-помилок у логіці обробника. Завжди узгоджуйте типи Pipe з TypeScript-сигнатурою.Pipe завжди виконується, якщо він застосований до параметра. Якщо потрібна умовна валідація, використовуйте логіку всередині Pipe:
@Injectable()
export class OptionalValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
if (!value) {
return value; // Пропускаємо валідацію для null/undefined
}
// Валідуємо лише якщо значення присутнє
return this.validate(value);
}
}
Альтернативно, для складної умовної логіки використовуйте Guards.
Приклад: комплексна валідація з використанням Pipes
Розглянемо реалістичний приклад створення користувача з множинними рівнями валідації:
// create-user.dto.ts
export class CreateUserDto {
email: string;
password: string;
age: number;
roles: string[];
}
// validate-age.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ValidateAgePipe implements PipeTransform<number, number> {
transform(age: number): number {
if (age < 18) {
throw new BadRequestException('User must be at least 18 years old');
}
if (age > 120) {
throw new BadRequestException('Invalid age value');
}
return age;
}
}
// validate-roles.pipe.ts
@Injectable()
export class ValidateRolesPipe implements PipeTransform<string[], string[]> {
private readonly allowedRoles = ['user', 'admin', 'moderator'];
transform(roles: string[]): string[] {
if (!Array.isArray(roles)) {
throw new BadRequestException('Roles must be an array');
}
const invalidRoles = roles.filter(role => !this.allowedRoles.includes(role));
if (invalidRoles.length > 0) {
throw new BadRequestException(
`Invalid roles: ${invalidRoles.join(', ')}. Allowed: ${this.allowedRoles.join(', ')}`
);
}
return roles;
}
}
Використання в контролері:
import { Controller, Post, Body } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { ValidateAgePipe } from './pipes/validate-age.pipe';
import { ValidateRolesPipe } from './pipes/validate-roles.pipe';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
async create(@Body() dto: CreateUserDto) {
// Валідація age відбувається в окремому Pipe
const validatedAge = await new ValidateAgePipe().transform(dto.age);
// Валідація roles відбувається в іншому Pipe
const validatedRoles = await new ValidateRolesPipe().transform(dto.roles);
return this.usersService.create({
...dto,
age: validatedAge,
roles: validatedRoles,
});
}
}
Проте більш елегантний підхід — застосувати Pipes безпосередньо до полів DTO через ValidationPipe з декораторами class-validator, що буде розглянуто в наступній лекції.
Порівняння Pipes з іншими компонентами
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// Pipe перетворює string → number
return this.service.findOne(id);
}
@Get(':id')
@UseGuards(RolesGuard)
@Roles('admin')
findOne(@Param('id') id: string) {
// Guard перевіряє права доступу
return this.service.findOne(id);
}
@Get(':id')
@UseInterceptors(TransformInterceptor)
findOne(@Param('id') id: string) {
// Interceptor обгортає результат у { data }
return this.service.findOne(id);
}
// app.module.ts
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
// Middleware логує всі запити
}
}
Кожен компонент має чітко визначену відповідальність у конвеєрі обробки запиту.
Підсумок: ключові концепції Pipes
Призначення
Інтерфейс
PipeTransform<T, R> з методом transform(value, metadata), що повертає трансформоване значення або викидає виключення.Рівні застосування
Обробка помилок
У наступній лекції ми детально розглянемо вбудовані Pipes, що надаються NestJS з коробки: ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, DefaultValuePipe та інші, з практичними прикладами їхнього використання.