Пошук уроків, статей та іншого контенту
Навчитеся читати помилки валідації, змінювати формат відповіді та повертати узгоджені HTTP-помилки клієнту.
У NestJS помилки валідації виникають під час виконання ValidationPipe. Якщо DTO не проходить перевірку, pipe не передає керування контролеру, а викидає HTTP-помилку зі статусом 400 Bad Request.
Наприклад, для такого DTO:
import { IsEmail, IsInt, IsString, Min } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
name: string;
@IsInt()
@Min(18)
age: number;
}запит:
{
"email": "invalid",
"name": 42,
"age": 15
}може повернути стандартну відповідь NestJS:
{
"statusCode": 400,
"message": [
"email must be an email",
"name must be a string",
"age must not be less than 18"
],
"error": "Bad Request"
}Стандартний формат підходить не кожному API. Клієнту часто потрібні:
стабільний код помилки;
зрозуміле повідомлення;
назва поля, якого стосується помилка;
код правила валідації;
однаковий формат для всіх endpoint-ів.
ValidationErrorФабрика винятку ValidationPipe отримує масив об’єктів ValidationError з пакета class-validator.
Спрощено один елемент має таку структуру:
{
property: 'email',
value: 'invalid',
constraints: {
isEmail: 'email must be an email'
},
children: []
}Основні властивості:
property — назва поля;
value — значення, передане клієнтом;
constraints — помилки правил валідації;
children — вкладені помилки для вкладених DTO або масивів.
Властивість value не варто повертати клієнту безпосередньо. Вона може містити пароль, токен або інші приватні дані.
Для зміни формату потрібно передати exceptionFactory до ValidationPipe.
Функція exceptionFactory отримує помилки валідації та повинна повернути виняток NestJS. У більшості випадків це BadRequestException.
Створимо функцію, яка перетворює дерево ValidationError на плоский список:
import { ValidationError } from 'class-validator';
export interface ValidationErrorDetail {
field: string;
code: string;
message: string;
}
export function flattenValidationErrors(
errors: ValidationError[],
parentPath = '',
): ValidationErrorDetail[] {
const details: ValidationErrorDetail[] = [];
for (const error of errors) {
const field = parentPath
? `${parentPath}.${error.property}`
: error.property;
if (error.constraints) {
for (const [code, message] of Object.entries(error.constraints)) {
details.push({
field,
code,
message,
});
}
}
if (error.children?.length) {
details.push(...flattenValidationErrors(error.children, field));
}
}
return details;
}Функція рекурсивно обробляє children, тому може працювати не лише з простими DTO.
Наприклад, вкладена помилка перетвориться на:
{
"field": "profile.address.city",
"code": "isString",
"message": "city must be a string"
}exceptionFactoryТепер можна сформувати стабільну відповідь для клієнта:
import {
BadRequestException,
HttpStatus,
ValidationPipe,
} from '@nestjs/common';
import { flattenValidationErrors } from './flatten-validation-errors';
export const validationPipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const details = flattenValidationErrors(errors);
return new BadRequestException({
statusCode: HttpStatus.BAD_REQUEST,
code: 'VALIDATION_ERROR',
message: 'Дані запиту не пройшли валідацію',
details,
});
},
});Підключення в main.ts:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { validationPipe } from './validation.pipe';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(validationPipe);
await app.listen(3000);
}
bootstrap();Тепер той самий некоректний запит матиме відповідь:
{
"statusCode": 400,
"code": "VALIDATION_ERROR",
"message": "Дані запиту не пройшли валідацію",
"details": [
{
"field": "email",
"code": "isEmail",
"message": "email must be an email"
},
{
"field": "name",
"code": "isString",
"message": "name must be a string"
},
{
"field": "age",
"code": "min",
"message": "age must not be less than 18"
}
]
}Такий формат простіше обробляти на frontend:
for (const error of response.details) {
form.setError(error.field, {
message: error.message,
});
}Нижче наведено мінімальну конфігурацію для endpoint-а створення користувача.
import { IsEmail, IsInt, IsString, Min } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
name: string;
@IsInt()
@Min(18)
age: number;
}import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './create-user.dto';
@Controller('users')
export class UsersController {
@Post()
create(@Body() dto: CreateUserDto) {
return {
id: 1,
email: dto.email,
name: dto.name,
age: dto.age,
};
}
}import { ValidationError } from 'class-validator';
export function flattenValidationErrors(
errors: ValidationError[],
parentPath = '',
) {
const details: Array<{
field: string;
code: string;
message: string;
}> = [];
for (const error of errors) {
const field = parentPath
? `${parentPath}.${error.property}`
: error.property;
for (const [code, message] of Object.entries(
error.constraints ?? {},
)) {
details.push({
field,
code,
message,
});
}
if (error.children?.length) {
details.push(...flattenValidationErrors(error.children, field));
}
}
return details;
}import {
BadRequestException,
HttpStatus,
ValidationPipe,
} from '@nestjs/common';
import { flattenValidationErrors } from './flatten-validation-errors';
export const appValidationPipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const details = flattenValidationErrors(errors);
return new BadRequestException({
statusCode: HttpStatus.BAD_REQUEST,
code: 'VALIDATION_ERROR',
message: 'Дані запиту не пройшли валідацію',
details,
});
},
});import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { appValidationPipe } from './app-validation.pipe';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(appValidationPipe);
await app.listen(3000);
}
bootstrap();Після цього всі DTO, які обробляються через @Body(), @Param() або @Query(), можуть використовувати єдиний формат помилок валідації.
whitelist і forbidNonWhitelistedПід час форматування помилок важливо розуміти поведінку додаткових полів.
whitelist: trueВластивості, яких немає в DTO, автоматично видаляються з об’єкта.
new ValidationPipe({
whitelist: true,
});Наприклад, поле isAdmin, якого немає в DTO, не потрапить до контролера.
forbidNonWhitelisted: trueЗамість мовчазного видалення поля NestJS поверне помилку:
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
});Для запиту:
{
"email": "user@example.com",
"name": "Anna",
"age": 25,
"isAdmin": true
}у details може з’явитися:
{
"field": "isAdmin",
"code": "whitelistValidation",
"message": "property isAdmin should not exist"
}Цей випадок обробляється тією самою функцією flattenValidationErrors, тому клієнт отримує узгоджений формат.
Повідомлення правил можна задати безпосередньо в DTO:
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsEmail({}, { message: 'Вкажіть коректну електронну адресу' })
email: string;
@IsString({ message: 'Ім’я має бути текстом' })
@MinLength(2, { message: 'Ім’я має містити щонайменше 2 символи' })
name: string;
}У відповіді повідомлення зміниться, але код правила залишиться стабільним:
{
"field": "name",
"code": "minLength",
"message": "Ім’я має містити щонайменше 2 символи"
}Код правила краще використовувати для програмної логіки клієнта, а текст — для відображення користувачу. Текст повідомлення може змінитися через локалізацію, тоді як minLength або isEmail залишаються ідентифікаторами правила.
Стандартні повідомлення class-validator можуть розкривати внутрішні назви полів або правила. Для публічного API іноді потрібно повертати лише безпечні дані.
Наприклад, можна не повертати оригінальний текст повідомлення:
exceptionFactory: (errors) => {
const fields = flattenValidationErrors(errors).map(({ field, code }) => ({
field,
code,
}));
return new BadRequestException({
statusCode: 400,
code: 'VALIDATION_ERROR',
message: 'Некоректні дані запиту',
details: fields,
});
},Відповідь:
{
"statusCode": 400,
"code": "VALIDATION_ERROR",
"message": "Некоректні дані запиту",
"details": [
{
"field": "email",
"code": "isEmail"
}
]
}Вибір між технічним повідомленням і безпечним повідомленням залежить від API. Для внутрішніх сервісів детальні повідомлення можуть бути корисними, але секретні значення все одно не слід повертати.
ValidationPipe працює не лише з тілом запиту. Він також обробляє DTO для параметрів маршруту та query-параметрів.
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
export class ListUsersQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsInt()
@Min(1)
page?: number;
}import { Controller, Get, Query } from '@nestjs/common';
import { ListUsersQueryDto } from './list-users-query.dto';
@Controller('users')
export class UsersController {
@Get()
findAll(@Query() query: ListUsersQueryDto) {
return {
query,
};
}
}Якщо використовується transform: true, NestJS може перетворювати примітивні значення відповідно до типів DTO. Наприклад, query-параметр ?page=2 може бути перетворений на число, якщо DTO та конфігурація дозволяють таке перетворення.
Це важливо, оскільки HTTP query-параметри початково надходять як рядки.
ValidationError — це внутрішня структура, яку створює class-validator.
Вона не надсилається клієнту автоматично як HTTP-відповідь. Послідовність виглядає так:
ValidationPipe перетворює вхідні дані.
class-validator перевіряє DTO.
Pipe отримує масив ValidationError.
exceptionFactory перетворює його на BadRequestException.
NestJS серіалізує виняток у HTTP-відповідь.
Клієнт отримує JSON із кодом статусу 400.
Тому змінювати потрібно не сам ValidationError, а формат винятку, який повертає exceptionFactory.
Якщо API використовує поле code для валідації, бажано використовувати такий самий підхід для інших помилок:
{
"statusCode": 404,
"code": "USER_NOT_FOUND",
"message": "Користувача не знайдено"
}Для валідації:
{
"statusCode": 400,
"code": "VALIDATION_ERROR",
"message": "Дані запиту не пройшли валідацію",
"details": []
}У результаті клієнт може перевіряти code, не аналізуючи текст повідомлення:
if (error.code === 'VALIDATION_ERROR') {
// Показати помилки біля полів форми
}
if (error.code === 'USER_NOT_FOUND') {
// Показати повідомлення про відсутнього користувача
}Статус 400 означає, що клієнт надіслав некоректні дані. Для помилок автентифікації, доступу або відсутнього ресурсу потрібно використовувати відповідні HTTP-статуси, а не маскувати їх під помилки валідації.
ValidationError напрямуНе варто повертати внутрішні об’єкти ValidationError без форматування:
exceptionFactory: (errors) => {
return new BadRequestException(errors);
};Такий підхід може розкрити value, вкладену внутрішню структуру та нестабільний формат бібліотеки.
Краще явно сформувати публічну модель помилки.
Не додавайте error.value до відповіді без потреби. Значення може містити:
пароль;
access token;
персональні дані;
великі об’єкти;
службову інформацію.
Такий код не побачить помилки вкладених DTO:
for (const error of errors) {
// Обробляються тільки constraints поточного рівня
}Потрібно рекурсивно обробляти children, якщо API використовує вкладені об’єкти.
Перевірка рядка:
if (message === 'email must be an email') {
// ...
}є крихкою. Повідомлення може змінитися через локалізацію або оновлення правил.
Для програмної логіки використовуйте стабільне поле code.
Помилки валідації запиту зазвичай мають статус 400. Не слід повертати 500 Internal Server Error, якщо проблема полягає в даних клієнта.
whitelistwhitelist: true і forbidNonWhitelisted: true мають різну поведінку:
whitelist: true видаляє невідомі поля;
forbidNonWhitelisted: true повідомляє про них помилкою.
Якщо потрібен строгий контракт API, зазвичай вмикають обидві опції.
ValidationPipe отримує помилки у вигляді масиву ValidationError.
constraints містить коди правил і повідомлення.
children містить помилки вкладених DTO.
exceptionFactory дає змогу повністю змінити HTTP-відповідь.
Власний формат може містити statusCode, code, message і details.
Значення полів не варто повертати клієнту.
Для обробки вкладених DTO потрібна рекурсивна функція.
Стабільні коди помилок надійніші за порівняння текстових повідомлень.
whitelist та forbidNonWhitelisted допомагають контролювати невідомі поля.
Узгоджений формат помилок спрощує роботу frontend і підтримку API.