Тема 9. Валідація даних та конвеєр обробки запитів у NestJS

Вбудовані pipes: ParseIntPipe, ParseBoolPipe, ParseUUIDPipe

Огляд стандартних pipes для трансформації параметрів

Вбудовані pipes: ParseIntPipe, ParseBoolPipe, ParseUUIDPipe

🎯 Мета лекції

  • Опанувати використання вбудованих Pipes для типових сценаріїв трансформації та валідації
  • Навчитися застосовувати ParseIntPipe, ParseFloatPipe, ParseBoolPipe для перетворення рядкових параметрів
  • Вивчити спеціалізовані pipes: ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe
  • Засвоїти механізм налаштування pipes через передачу опцій конфігурації
  • Зрозуміти роль DefaultValuePipe для обробки опційних параметрів
  • Практикувати комбінування pipes для створення складної логіки валідації

🔑 Ключові терміни

  • Parse Pipe (парсинг-pipe): pipe, що перетворює рядок у конкретний тип (число, boolean, UUID)
  • DefaultValuePipe (pipe значення за замовчуванням): pipe, що встановлює fallback-значення для відсутніх параметрів
  • UUID (Universally Unique Identifier): стандартизований формат унікального ідентифікатора (128-біт)
  • Enum Validation (валідація перелічення): перевірка, чи належить значення до визначеного набору констант
  • Optional Parameters (опційні параметри): параметри, що можуть бути відсутніми в запиті
  • Error Status Code (код статусу помилки): HTTP-код, що повертається при невалідних даних (зазвичай 400)

ParseIntPipe: перетворення рядків у цілі числа

ParseIntPipe є одним з найбільш часто використовуваних pipes у NestJS-застосунках. Він розв'язує фундаментальну проблему: HTTP-параметри (як у URL, так і в query string) передаються як рядки, навіть якщо вони представляють числа.

Проблема без ParseIntPipe

Без використання pipe TypeScript-компілятор «думає», що параметр має тип number, але насправді він є рядком:

@Get(':id')
findOne(@Param('id') id: number) {
  console.log(typeof id); // Виведе: "string" ❌
  console.log(id === 123); // false, навіть якщо URL містить /123
  console.log(id === "123"); // true
  
  // Небезпечна операція: JavaScript приведе типи неявно
  const nextId = id + 1; // "1231" замість 124 ❌
  
  return this.usersService.findOne(id);
}

Це призводить до type coercion (неявного приведення типів) та потенційних помилок у бізнес-логіці.

Рішення з ParseIntPipe

import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    console.log(typeof id); // "number" ✅
    console.log(id === 123); // true ✅
    
    const nextId = id + 1; // 124 ✅
    
    return this.usersService.findOne(id);
  }
}

Якщо клієнт надішле невалідний параметр (наприклад, /users/abc), ParseIntPipe автоматично викине BadRequestException:

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}
ParseIntPipe використовує стандартну JavaScript-функцію parseInt(value, 10) для перетворення. Це означає, що рядки на кшталт "123abc" будуть перетворені на 123 (часткове парсування). Якщо потрібна строга валідація, розгляньте створення власного pipe або використання class-validator.

Налаштування ParseIntPipe

ParseIntPipe приймає опційний об'єкт конфігурації:

import { ParseIntPipe, HttpStatus } from '@nestjs/common';

@Get(':id')
findOne(
  @Param('id', new ParseIntPipe({
    errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE, // 406 замість 400
    optional: false, // За замовчуванням false
    exceptionFactory: (error) => {
      // Кастомне повідомлення про помилку
      return new BadRequestException(`ID must be a valid integer: ${error}`);
    }
  }))
  id: number
) {
  return this.usersService.findOne(id);
}

Опції конфігурації:

ОпціяТипОпис
errorHttpStatusCodeHttpStatusHTTP-статус для помилки (за замовчуванням 400)
optionalbooleanДозволити undefined (за замовчуванням false)
exceptionFactory(error: string) => anyФункція для створення кастомного виключення

ParseFloatPipe: числа з плаваючою комою

ParseFloatPipe працює аналогічно до ParseIntPipe, але використовує parseFloat() для підтримки десяткових дробів:

@Get('products')
findProducts(
  @Query('minPrice', ParseFloatPipe) minPrice: number,
  @Query('maxPrice', ParseFloatPipe) maxPrice: number
) {
  console.log(typeof minPrice); // "number"
  console.log(minPrice); // 19.99 (не "19.99")
  
  return this.productsService.findInPriceRange(minPrice, maxPrice);
}

Приклад запиту:

GET /products?minPrice=19.99&maxPrice=99.50

Параметри minPrice та maxPrice будуть автоматично перетворені з рядків "19.99" та "99.50" у числа 19.99 та 99.5.

parseFloat() ігнорує нецифрові символи після числа: "123.45abc" → 123.45. Для строгої валідації використовуйте ValidationPipe з декораторами class-validator (@IsNumber(), @IsPositive()).

ParseBoolPipe: конвертація рядків у boolean

ParseBoolPipe перетворює рядкові представлення булевих значень у справжні boolean:

@Get('articles')
findAll(
  @Query('published', ParseBoolPipe) published: boolean,
  @Query('featured', ParseBoolPipe) featured: boolean
) {
  console.log(typeof published); // "boolean"
  console.log(published === true); // true, не "true"
  
  return this.articlesService.findAll({ published, featured });
}

Приклад запиту:

GET /articles?published=true&featured=false

Правила конвертації ParseBoolPipe

ParseBoolPipe розпізнає наступні значення:

РядокРезультат
"true", "1", "yes"true
"false", "0", "no"false
Будь-що іншеBadRequestException
// ✅ Валідні запити
GET /articles?published=true
GET /articles?published=false
GET /articles?published=1
GET /articles?published=0

// ❌ Невалідні запити (викинуть 400)
GET /articles?published=yes   // "yes" не підтримується стандартним ParseBoolPipe
GET /articles?published=maybe
GET /articles?published=
Для підтримки більш гнучкого парсування (наприклад, "yes", "on", "enabled") створіть власний pipe:
@Injectable()
export class FlexibleParseBoolPipe implements PipeTransform<string, boolean> {
  private readonly truthyValues = ['true', '1', 'yes', 'on', 'enabled'];
  private readonly falsyValues = ['false', '0', 'no', 'off', 'disabled'];

  transform(value: string): boolean {
    const lowerValue = value.toLowerCase();
    
    if (this.truthyValues.includes(lowerValue)) {
      return true;
    }
    if (this.falsyValues.includes(lowerValue)) {
      return false;
    }
    
    throw new BadRequestException(
      `Boolean value expected, received: "${value}"`
    );
  }
}

ParseUUIDPipe: валідація унікальних ідентифікаторів

UUID (Universally Unique Identifier) є стандартизованим форматом для унікальних ідентифікаторів, що часто використовується замість автоінкрементних числових ID у розподілених системах. Формат UUID: 550e8400-e29b-41d4-a716-446655440000.

ParseUUIDPipe перевіряє, чи відповідає рядок валідному UUID-формату:

import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';

@Controller('orders')
export class OrdersController {
  @Get(':id')
  findOne(@Param('id', ParseUUIDPipe) id: string) {
    // id гарантовано є валідним UUID
    return this.ordersService.findOne(id);
  }
}

Приклади запитів:

# ✅ Валідний UUID
GET /orders/550e8400-e29b-41d4-a716-446655440000

# ❌ Невалідний UUID (викине 400)
GET /orders/123
GET /orders/not-a-uuid
GET /orders/550e8400-e29b-41d4-a716  # Неповний UUID

Версії UUID

ParseUUIDPipe підтримує різні версії UUID (v1, v3, v4, v5). За замовчуванням приймаються всі версії, але можна обмежити:

@Get(':id')
findOne(
  @Param('id', new ParseUUIDPipe({ version: '4' })) id: string
) {
  // Приймає лише UUIDv4 (найпоширеніший формат)
  return this.ordersService.findOne(id);
}

Опції ParseUUIDPipe:

ОпціяТипОпис
version'3' | '4' | '5'Конкретна версія UUID (за замовчуванням будь-яка)
errorHttpStatusCodeHttpStatusСтатус помилки (за замовчуванням 400)
exceptionFactory(error: string) => anyКастомна фабрика виключень
UUID v4 є найпоширенішою версією, що генерується випадковим чином. UUID v1 містить часову мітку та MAC-адресу, UUID v3/v5 базуються на хешуванні (MD5/SHA-1). Для більшості застосунків достатньо використовувати UUIDv4.

ParseEnumPipe: валідація значень з перелічення

ParseEnumPipe перевіряє, чи належить значення до визначеного TypeScript enum:

// Визначення enum
export enum UserRole {
  Admin = 'admin',
  User = 'user',
  Moderator = 'moderator',
}

@Controller('users')
export class UsersController {
  @Get()
  findByRole(
    @Query('role', new ParseEnumPipe(UserRole)) role: UserRole
  ) {
    console.log(role); // 'admin' | 'user' | 'moderator'
    return this.usersService.findByRole(role);
  }
}

Приклади запитів:

# ✅ Валідні значення
GET /users?role=admin
GET /users?role=user
GET /users?role=moderator

# ❌ Невалідні значення (викинуть 400)
GET /users?role=superuser
GET /users?role=guest
GET /users?role=Admin  # Чутливий до регістру!

Повідомлення про помилку автоматично включає список дозволених значень:

{
  "statusCode": 400,
  "message": "Validation failed (expected one of: admin, user, moderator)",
  "error": "Bad Request"
}
Для enum з числовими значеннями ParseEnumPipe автоматично перетворює рядок у число:
enum Priority {
  Low = 1,
  Medium = 2,
  High = 3,
}

@Get('tasks')
findByPriority(
  @Query('priority', new ParseEnumPipe(Priority)) priority: Priority
) {
  console.log(typeof priority); // "number"
  console.log(priority); // 1, 2 або 3
}

// GET /tasks?priority=2 → priority = Priority.Medium (2)

ParseArrayPipe: перетворення рядків у масиви

ParseArrayPipe дозволяє парсити параметри, що представляють масиви значень. Це корисно для query string параметрів, що містять списки:

import { Controller, Get, Query, ParseArrayPipe } from '@nestjs/common';

@Controller('products')
export class ProductsController {
  @Get()
  findByTags(
    @Query('tags', new ParseArrayPipe({ items: String, separator: ',' }))
    tags: string[]
  ) {
    console.log(tags); // ['electronics', 'gadgets', 'sale']
    return this.productsService.findByTags(tags);
  }
}

Приклад запиту:

GET /products?tags=electronics,gadgets,sale

Налаштування ParseArrayPipe

Обов'язкові опції:

ОпціяТипОпис
itemsType<any>Тип елементів масиву (String, Number, класи DTO)
separatorstringРоздільник елементів (за замовчуванням ',')

Опційні налаштування:

ОпціяТипОпис
optionalbooleanДозволити undefined
whitelistbooleanВидалити невідомі властивості (для об'єктів)

Парсинг масивів чисел

@Get('users')
findByIds(
  @Query('ids', new ParseArrayPipe({ items: Number, separator: ',' }))
  ids: number[]
) {
  console.log(ids); // [1, 2, 3, 5, 8]
  console.log(typeof ids[0]); // "number"
  return this.usersService.findByIds(ids);
}

// GET /users?ids=1,2,3,5,8

Парсинг масивів DTO-об'єктів

Для складніших структур ParseArrayPipe може парсити JSON-масиви:

export class FilterDto {
  field: string;
  operator: string;
  value: any;
}

@Get('search')
search(
  @Query('filters', new ParseArrayPipe({ items: FilterDto }))
  filters: FilterDto[]
) {
  return this.searchService.applyFilters(filters);
}

// GET /search?filters=[{"field":"age","operator":"gt","value":18}]
Для парсингу JSON-масивів через query string клієнт має правильно закодувати значення через encodeURIComponent(). Альтернативно, передавайте складні структури через тіло POST-запиту.

DefaultValuePipe: значення за замовчуванням

DefaultValuePipe встановлює fallback-значення для опційних параметрів, що відсутні в запиті. Це особливо корисно для параметрів пагінації:

@Get()
findAll(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
) {
  console.log(page); // 1, якщо параметр відсутній
  console.log(limit); // 10, якщо параметр відсутній
  
  return this.usersService.findAll({ page, limit });
}

Порядок виконання pipes:

  1. DefaultValuePipe перевіряє наявність значення:
    • Якщо параметр відсутній (undefined або null) → повертає значення за замовчуванням
    • Якщо параметр присутній → передає його далі без змін
  2. ParseIntPipe перетворює рядок у число

Приклади запитів:

# Обидва параметри відсутні → page=1, limit=10
GET /users

# Частково присутні → page=5, limit=10
GET /users?page=5

# Обидва присутні → page=2, limit=50
GET /users?page=2&limit=50
DefaultValuePipe має бути перед трансформуючими pipes (наприклад, ParseIntPipe), оскільки він працює з undefined, а ParseIntPipe очікує рядок або число.

Комплексні значення за замовчуванням

DefaultValuePipe може встановлювати складні структури:

@Get('search')
search(
  @Query('filters', new DefaultValuePipe([]), ParseArrayPipe)
  filters: any[]
) {
  // filters завжди масив, навіть якщо параметр відсутній
  console.log(Array.isArray(filters)); // true
}

Комбінування множинних pipes

Потужна можливість NestJS полягає у композиції pipes для створення складної логіки валідації та трансформації:

@Get()
findAll(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('limit', new DefaultValuePipe(10), ParseIntPipe, new MaxValuePipe(100)) limit: number,
  @Query('sort', new DefaultValuePipe('createdAt'), new ParseEnumPipe(SortField)) sort: SortField,
  @Query('active', new DefaultValuePipe('true'), ParseBoolPipe) active: boolean
) {
  return this.usersService.findAll({ page, limit, sort, active });
}

Ланцюг обробки для параметра limit:

  1. DefaultValuePipe(10) → якщо відсутній, встановлює 10
  2. ParseIntPipe → перетворює рядок у число
  3. MaxValuePipe(100) → перевіряє, чи не перевищує 100
Loading diagram...
flowchart LR
    Start([Query Param<br/>limit]) --> Check{Параметр<br/>присутній?}
    Check -->|Ні| Default[DefaultValuePipe<br/>встановлює 10]
    Check -->|Так| Parse[ParseIntPipe<br/>string → number]
    Default --> Parse
    Parse --> Validate[MaxValuePipe<br/>перевірка ≤ 100]
    Validate --> Valid{Валідний?}
    Valid -->|Так| Handler([Route Handler<br/>отримує число])
    Valid -->|Ні| Error([BadRequestException<br/>400])
    
    style Start fill:#64748b,stroke:#334155,color:#ffffff
    style Check fill:#f59e0b,stroke:#b45309,color:#1e293b
    style Default fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Parse fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Validate fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Valid fill:#f59e0b,stroke:#b45309,color:#1e293b
    style Handler fill:#22c55e,stroke:#15803d,color:#ffffff
    style Error fill:#ef4444,stroke:#b91c1c,color:#ffffff

Практичний приклад: API пагінації з повною валідацією

Розглянемо реалістичний приклад створення ендпоінту з комплексною валідацією параметрів:

import {
  Controller,
  Get,
  Query,
  ParseIntPipe,
  ParseBoolPipe,
  ParseEnumPipe,
  DefaultValuePipe,
  BadRequestException,
} from '@nestjs/common';

// Enum для сортування
export enum SortOrder {
  ASC = 'asc',
  DESC = 'desc',
}

// Кастомний Pipe для обмеження діапазону
class RangePipe implements PipeTransform<number, number> {
  constructor(private min: number, private max: number) {}

  transform(value: number): number {
    if (value < this.min || value > this.max) {
      throw new BadRequestException(
        `Value must be between ${this.min} and ${this.max}`
      );
    }
    return value;
  }
}

@Controller('articles')
export class ArticlesController {
  @Get()
  findAll(
    // Пагінація: page від 1 до 1000, за замовчуванням 1
    @Query('page', new DefaultValuePipe(1), ParseIntPipe, new RangePipe(1, 1000))
    page: number,

    // Розмір сторінки: limit від 1 до 100, за замовчуванням 20
    @Query('limit', new DefaultValuePipe(20), ParseIntPipe, new RangePipe(1, 100))
    limit: number,

    // Сортування: asc або desc, за замовчуванням desc
    @Query('order', new DefaultValuePipe(SortOrder.DESC), new ParseEnumPipe(SortOrder))
    order: SortOrder,

    // Фільтр: тільки опубліковані, за замовчуванням true
    @Query('published', new DefaultValuePipe('true'), ParseBoolPipe)
    published: boolean,

    // Теги: опційний масив рядків
    @Query('tags', new DefaultValuePipe([]), new ParseArrayPipe({ items: String, separator: ',' }))
    tags: string[]
  ) {
    const offset = (page - 1) * limit;

    return this.articlesService.findAll({
      offset,
      limit,
      order,
      published,
      tags,
    });
  }
}

Приклади запитів:

# Мінімальний запит (всі значення за замовчуванням)
GET /articles
# → page=1, limit=20, order=desc, published=true, tags=[]

# З пагінацією
GET /articles?page=3&limit=50
# → page=3, limit=50, order=desc, published=true, tags=[]

# З фільтрацією
GET /articles?published=false&tags=typescript,nestjs
# → page=1, limit=20, order=desc, published=false, tags=['typescript','nestjs']

# Повна конфігурація
GET /articles?page=2&limit=30&order=asc&published=true&tags=tutorial,backend

Помилкові запити:

# ❌ page поза діапазоном
GET /articles?page=2000
# → 400: Value must be between 1 and 1000

# ❌ limit поза діапазоном
GET /articles?limit=500
# → 400: Value must be between 1 and 100

# ❌ Невалідний order
GET /articles?order=random
# → 400: Validation failed (expected one of: asc, desc)

# ❌ Невалідний тип page
GET /articles?page=abc
# → 400: Validation failed (numeric string is expected)

Підсумок: коли використовувати які pipes

ParseIntPipe / ParseFloatPipe

Для ID ресурсів, параметрів пагінації (page, limit), числових фільтрів (age, price).

ParseBoolPipe

Для прапорців фільтрації (published, active, featured), опцій конфігурації.

ParseUUIDPipe

Для унікальних ідентифікаторів у розподілених системах, foreign keys, токенів.

ParseEnumPipe

Для обмежених наборів значень: статуси (pending, approved), ролі, типи.

ParseArrayPipe

Для множинних фільтрів (tags, categories), списків ID, bulk-операцій.

DefaultValuePipe

Для опційних параметрів з розумними значеннями за замовчуванням (page=1, limit=10).

У наступній лекції ми розглянемо ValidationPipe — найпотужніший вбудований pipe, що інтегрується з бібліотекою class-validator для декларативної валідації складних DTO-структур через декоратори.

Copyright © 2026