Skip to main content

Що таке pipes у NestJS?

Pipes у NestJS - це функції, що перехоплюють вхідні дані запиту (параметри, query, body) до того, як вони потрапляють до обробника маршруту, реалізуючи інтерфейс PipeTransform. Вони або перетворюють сире значення на потрібний тип, або кидають HTTP 400, якщо дані невалідні.

Теорія

TL;DR

  • Уяви pipe як сканер в аеропорту: сирі рядки з URL перевіряються і конвертуються; невалідні дані відхиляються одразу, правильні проходять у потрібному типі
  • Два завдання: трансформація ("123"123) і валідація (відхилити не-ціле число, невалідний email, неправильний UUID)
  • HTTP завжди передає параметри і query-рядки як strings. Pipes виправляють це автоматично
  • ValidationPipe для валідації DTO; ParseIntPipe, ParseUUIDPipe - для примітивів
  • Правило: завжди використовуй pipes для вхідних HTTP-даних; для довірених внутрішніх викликів можна пропустити

Швидкий приклад

typescript
// Без pipe: id завжди рядок з URL @Get(':id') findOne(@Param('id') id: string) { return this.usersService.findOne(+id); // ручний парсинг, падає на "abc" } // З ParseIntPipe: автоматичний парсинг і валідація, кидає 400 при помилці @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.usersService.findOne(id); // id - число або 400 Bad Request } // GET /users/123 → id = 123 // GET /users/abc → 400 "Validation failed (numeric string is expected)"

ParseIntPipe виконується до тіла обробника. Якщо значення не є цілим числом, NestJS кидає BadRequestException і обробник не викликається взагалі.

Головна відмінність від ручного парсингу

Ручний парсинг всередині обробника (+id, parseInt(id)) змішує валідацію даних з бізнес-логікою. На невалідному вводі він тихо повертає NaN, який потім потрапляє в сервіс і викликає збої далеко від джерела. Pipes перехоплюють проблему на вході, до будь-якої логіки, і повертають стандартну відповідь 400.

Коли використовувати

  • URL-параметри і query-рядки: завжди використовуй ParseIntPipe, ParseUUIDPipe тощо. Express передає їх як рядки.
  • Тіло POST/PUT-запиту: ValidationPipe разом із DTO-класом і декораторами class-validator.
  • Необов'язкові query-параметри зі значеннями за замовчуванням: постав DefaultValuePipe перед ParseIntPipe.
  • Глобальна валідація: app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })) в main.ts.
  • Перевірки на рівні бізнес-логіки (унікальність email, існування в БД): кастомний pipe з реалізацією PipeTransform.

Вбудовані pipes

PipeЩо робитьПриклад
ValidationPipeВалідує DTO через class-validator{ age: "25" }{ age: 25 } або 400
ParseIntPipeрядок → ціле число, відхиляє не-цілі"123" → 123; "abc" → 400
ParseFloatPipeрядок → число з плаваючою комою"12.5" → 12.5
ParseBoolPipeрядок → булевий тип"true" → true
ParseUUIDPipeВалідує формат UUIDневалідний UUID → 400
ParseEnumPipeВалідує проти значень enumзначення не в enum → 400
DefaultValuePipeПовертає значення за замовчуваннямнемає limit → 10
ParseArrayPipeРядок через кому → масив"1,2,3" → [1, 2, 3]

Рівні прив'язки

Pipes можна застосовувати на чотирьох рівнях. Більш специфічний завжди перекриває менш специфічний.

typescript
// 1. Рівень параметра (найбільш специфічний) @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) {} // 2. Рівень методу @Post() @UsePipes(ValidationPipe) create(@Body() dto: CreateUserDto) {} // 3. Рівень контролера @Controller('users') @UsePipes(new ValidationPipe()) export class UsersController {} // 4. Глобальний (в main.ts) app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); // або через DI-токен: { provide: APP_PIPE, useClass: ValidationPipe }

Глобальні pipes виконуються на кожному запиті у всьому додатку. Це правильний підхід для ValidationPipe. Для парсингу конкретних типів, наприклад ParseIntPipe, застосовуй на рівні параметра - там, де тобі справді потрібне число.

Як NestJS виконує pipes

Під час реєстрації маршрутів NestJS прив'язує pipes до конкретних аргументів, використовуючи метадані з декораторів (@Param, @Body, @Query). На кожному запиті він послідовно викликає transform(value, metadata) для кожного pipe перед тим, як передати аргументи до обробника. Якщо transform() кидає BadRequestException, фільтр виключень повертає 400. Обробник не викликається.

Кастомні pipes можуть бути async. NestJS чекає на результат перед тим, як продовжити, тому запити до БД всередині transform() працюють без жодного додаткового налаштування.

Типові помилки

Помилка 1: new ValidationPipe() без { transform: true }.

Без цього опції поля DTO залишаються рядками навіть якщо типізовані як number. Валідатори на кшталт @IsNumber() тоді падають на рядковому значенні. З того, що я бачив у продакшн-проектах, це найпоширеніша помилка з pipes: все виглядає правильно, поки десь тихо не зламається числове порівняння.

typescript
// Неправильно: age залишається "25" (рядок), @IsNumber() не спрацьовує app.useGlobalPipes(new ValidationPipe()); // Правильно app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));

Помилка 2: @Body(ValidationPipe) без DTO-класу.

ValidationPipe очікує клас із метаданими від декораторів. Без DTO немає чого валідувати і кидається незрозуміла помилка.

typescript
// Неправильно @Post() create(@Body(ValidationPipe) body: any) {} // Правильно: завжди разом з DTO @Post() create(@Body() dto: CreateUserDto) {}

Помилка 3: Неправильний порядок pipes на одному параметрі.

Pipes виконуються зліва направо. Якщо поставити ValidationPipe перед ParseIntPipe, він намагатиметься валідувати рядок, а не число.

typescript
// Неправильно: валідує рядок, потім парсить @Param('id', ValidationPipe, ParseIntPipe) // Правильно: спочатку парсимо, потім валідуємо @Param('id', ParseIntPipe, ValidationPipe)

Помилка 4: new Error() в кастомному pipe замість BadRequestException.

Звичайна Error стає відповіддю 500 Internal Server Error. Pipes знаходяться в HTTP-шарі і мають кидати HTTP-виключення NestJS.

typescript
// Неправильно: відповідь 500 throw new Error('Invalid value'); // Правильно: відповідь 400 throw new BadRequestException('Invalid value');

Помилка 5: Забутий async/await у кастомному pipe з запитом до БД.

typescript
// Неправильно: повертає нерозв'язаний Promise як значення transform(value: string) { const exists = this.service.exists(value); // немає await! if (exists) throw new BadRequestException(); return value; } // Правильно async transform(value: string) { const exists = await this.service.exists(value); if (exists) throw new BadRequestException('Вже зайнятий'); return value; }

Де зустрічається в реальних проектах

  • main.ts більшості NestJS-додатків: глобальний ValidationPipe з whitelist: true і transform: true
  • Ендпоінти авторизації: ParseUUIDPipe на @Param('userId') відхиляє невалідні ID до звернення до БД
  • Prisma-додатки: кастомні pipes валідують значення enum перед передачею в Prisma-запити
  • GraphQL-резолвери: ValidationPipe на @Args() працює так само, як і на REST-контролерах
  • Мікросервіси NestJS: pipes валідують payload для MessagePattern, той самий API PipeTransform

Питання на співбесіді

Q: Яка різниця між глобальним pipe і pipe на рівні маршруту?
A: Глобальні pipes виконуються на кожному запиті в усіх контролерах. Pipes на рівні маршруту або параметра - тільки там, де задекоровано. Глобальний підхід для загальної валідації DTO, локальний для конкретного перетворення типів.

Q: Чи може pipe отримати доступ до повного об'єкта HTTP-запиту?
A: Не напряму через аргументи transform(). В кастомному pipe можна заінжектити REQUEST scope або використати ExecutionContext. Більшість задач цього не потребує.

Q: Як ValidationPipe обробляє вкладені DTO?
A: Додай @ValidateNested() на вкладену властивість і @Type(() => NestedDto) з class-transformer. Для масивів використовуй each: true у валідаторі.

Q: Що відбувається, якщо перший з двох pipes на параметрі кидає виключення?
A: Виконання зупиняється одразу. Другий pipe не виконується, обробник не викликається, виключення передається до фільтра виключень.

Q (Senior): Тобі потрібно обмежити кількість запитів за IP через Redis всередині pipe. Які підводні камені?
A: Потрібен request scope для читання IP - через Inject(REQUEST) або ExecutionContext. transform() має бути async. Огорни виклик Redis у try/catch з fallback, інакше таймаут Redis перетворюється на 500. Тестуй під конкурентним навантаженням: без атомарних операцій (INCR + EXPIRE в одній транзакції) виникнуть race conditions.

Приклади

Базовий: парсинг параметра URL

typescript
import { Controller, Get, Param } from '@nestjs/common'; import { ParseIntPipe } from '@nestjs/common'; @Controller('cats') export class CatsController { @Get(':id') findCat(@Param('id', ParseIntPipe) id: number) { // id завжди число тут return `Cat #${id}`; } } // GET /cats/5 → "Cat #5" // GET /cats/foo → 400 "Validation failed (numeric string is expected)"

ParseIntPipe виконується до тіла методу. Обробник отримує тільки валідні цілі числа.

Середній: валідація DTO на POST-ендпоінті

typescript
import { IsEmail, IsInt, Min } from 'class-validator'; import { Body, Controller, Param, Post } from '@nestjs/common'; import { ParseIntPipe, ValidationPipe } from '@nestjs/common'; class UpdateUserDto { @IsEmail() email: string; @IsInt() @Min(18) age: number; } @Controller('users') export class UsersController { @Post(':id') updateUser( @Param('id', ParseIntPipe) id: number, @Body(new ValidationPipe({ transform: true })) dto: UpdateUserDto, ) { // id - число, email валідований, age - число >= 18 return this.usersService.update(id, dto); } } // POST /users/1 { "email": "test@example.com", "age": "20" } // → dto = { email: "test@example.com", age: 20 } // POST /users/1 { "email": "bad", "age": 15 } // → 400 з деталями помилок валідації

Опція transform: true конвертує "20" (рядок з JSON) в 20 (число) до того, як спрацює перевірка @IsInt(). Без неї перевірка падає на рядку.

Просунутий: кастомний асинхронний pipe для перевірки унікальності

typescript
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; import { UsersService } from './users.service'; @Injectable() export class UniqueEmailPipe implements PipeTransform<string> { constructor(private readonly usersService: UsersService) {} async transform(value: string, metadata: ArgumentMetadata): Promise<string> { const exists = await this.usersService.emailExists(value); if (exists) { throw new BadRequestException(`Email "${value}" вже зареєстрований`); } return value; } } // Використання в контролері @Post() async createUser( @Body('email', UniqueEmailPipe) email: string, ) { return this.usersService.create({ email }); }

Три речі роблять це правильним. UniqueEmailPipe позначений @Injectable(), тому NestJS сам обробляє залежності і сервіс доступний через конструктор. transform() - async, і NestJS чекає на результат перед викликом обробника. І BadRequestException замість звичайної Error - відповідь буде 400, а не 500.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?