Пошук уроків, статей та іншого контенту
Створите узгоджену структуру помилок і налаштуєте винятки, фільтри та безпечне розкриття деталей.
У застосунку на NestJS помилки можуть виникати в різних місцях:
у контролері;
у сервісі;
під час валідації вхідних даних;
у guard або interceptor;
під час роботи з базою даних;
через непередбачений програмний дефект.
Якщо кожен endpoint повертає помилки у власному форматі, клієнт змушений обробляти багато різних структур. Узгоджена архітектура має забезпечити:
однакову структуру HTTP-відповіді;
стабільний машинний код помилки;
зрозуміле повідомлення для користувача;
додаткові деталі лише там, де це безпечно;
відсутність stack trace та внутрішніх даних у production-відповіді;
повне логування непередбачених помилок на сервері.
Наприклад, усі помилки API можуть мати таку структуру:
{
"statusCode": 404,
"code": "USER_NOT_FOUND",
"message": "Користувача не знайдено",
"path": "/users/42",
"timestamp": "2026-09-01T10:15:30.000Z"
}Для помилки валідації поле details може містити структуровану інформацію:
{
"statusCode": 400,
"code": "VALIDATION_ERROR",
"message": "Дані запиту не пройшли перевірку",
"details": [
{
"field": "email",
"rule": "isEmail",
"message": "email must be an email"
}
],
"path": "/users",
"timestamp": "2026-09-01T10:16:00.000Z"
}У NestJS виняток — це об'єкт, який перериває нормальне виконання обробника. Для HTTP-помилок NestJS використовує класи на основі HttpException:
throw new NotFoundException('Користувача не знайдено');Вбудовані класи включають:
BadRequestException — 400;
UnauthorizedException — 401;
ForbiddenException — 403;
NotFoundException — 404;
ConflictException — 409;
UnprocessableEntityException — 422;
InternalServerErrorException — 500.
Вбудовані винятки зручні, але їх недостатньо для великого API. Повідомлення винятку не повинно бути єдиним ідентифікатором помилки. Клієнту потрібен стабільний код, наприклад USER_NOT_FOUND, який не залежить від тексту повідомлення.
Створимо виняток, який зберігатиме:
code — стабільний код помилки;
HTTP-статус;
безпечне повідомлення;
необов'язкові деталі.
// src/common/errors/app.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';
export type ErrorDetails = unknown;
export class AppException extends HttpException {
constructor(
public readonly code: string,
status: HttpStatus,
message: string,
public readonly details?: ErrorDetails,
) {
super(
{
code,
message,
...(details !== undefined ? { details } : {}),
},
status,
);
}
}Тепер доменна логіка може викидати помилки з узгодженими кодами:
throw new AppException(
'USER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'Користувача не знайдено',
);Код USER_NOT_FOUND призначений для програмної обробки клієнтом. Повідомлення може бути локалізоване або змінене без порушення контракту API.
Для часто повторюваних або семантично важливих помилок зручно створювати спеціалізовані класи:
import { HttpStatus } from '@nestjs/common';
import { AppException } from './app.exception';
export class UserNotFoundException extends AppException {
constructor(userId: string) {
super(
'USER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'Користувача не знайдено',
{ userId },
);
}
}Однак у details потрібно передавати лише безпечні дані. Ідентифікатор користувача зазвичай безпечний, а пароль, токен або SQL-запит — ні.
Exception filter перехоплює винятки перед формуванням HTTP-відповіді. Саме він може перетворити різні типи винятків на єдину структуру.
Фільтр має розрізняти щонайменше три випадки:
AppException — контрольована помилка застосунку;
HttpException — стандартна HTTP-помилка NestJS;
невідома помилка — внутрішня помилка сервера.
// src/common/errors/error-response.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
Injectable,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { AppException } from './app.exception';
@Injectable()
@Catch()
export class ErrorResponseFilter implements ExceptionFilter {
private readonly logger = new Logger(ErrorResponseFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const context = host.switchToHttp();
const request = context.getRequest();
const response = context.getResponse();
let statusCode = 500;
let code = 'INTERNAL_SERVER_ERROR';
let message = 'Внутрішня помилка сервера';
let details: unknown;
if (exception instanceof AppException) {
statusCode = exception.getStatus();
code = exception.code;
message = exception.message;
details = exception.details;
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
code = `HTTP_${statusCode}`;
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (
exceptionResponse &&
typeof exceptionResponse === 'object' &&
'message' in exceptionResponse
) {
const responseMessage = (exceptionResponse as { message?: unknown })
.message;
if (typeof responseMessage === 'string') {
message = responseMessage;
}
}
} else {
this.logUnknownException(exception);
}
const payload = {
statusCode,
code,
message,
...(details !== undefined ? { details } : {}),
path: httpAdapter.getRequestUrl(request),
timestamp: new Date().toISOString(),
};
httpAdapter.reply(response, payload, statusCode);
}
private logUnknownException(exception: unknown): void {
if (exception instanceof Error) {
this.logger.error(exception.message, exception.stack);
return;
}
this.logger.error('Невідома помилка без об’єкта Error');
}
}HttpAdapterHost дає доступ до адаптера NestJS. Це дозволяє фільтру працювати незалежно від того, використовується Express чи Fastify.
Внутрішня помилка може містити:
назву таблиці бази даних;
SQL-запит;
шлях до файлу;
секретний ключ;
stack trace;
дані конфігурації;
фрагменти персональних даних.
Тому клієнт має отримати лише загальне повідомлення:
{
"statusCode": 500,
"code": "INTERNAL_SERVER_ERROR",
"message": "Внутрішня помилка сервера"
}Повний stack trace потрібно записати в серверний лог, де його зможуть використати розробники.
Якщо фільтр використовує залежності NestJS, його краще зареєструвати як provider, а не створювати через new.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ErrorResponseFilter } from './common/errors/error-response.filter';
import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service';
@Module({
controllers: [UsersController],
providers: [UsersService, ErrorResponseFilter],
})
export class AppModule {}// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ErrorResponseFilter } from './common/errors/error-response.filter';
import { ValidationAppException } from './common/errors/validation-app.exception';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
exceptionFactory: (errors) =>
new ValidationAppException(errors),
}),
);
app.useGlobalFilters(app.get(ErrorResponseFilter));
await app.listen(3000);
}
void bootstrap();Такий порядок гарантує, що помилки валідації також проходять через єдиний фільтр.
Стандартний ValidationPipe може повернути структуру, яка відрізняється від помилок бізнес-логіки. Створимо окремий виняток для валідації.
// src/common/errors/validation-app.exception.ts
import {
HttpStatus,
ValidationError,
} from '@nestjs/common';
import { AppException } from './app.exception';
export class ValidationAppException extends AppException {
constructor(errors: ValidationError[]) {
super(
'VALIDATION_ERROR',
HttpStatus.BAD_REQUEST,
'Дані запиту не пройшли перевірку',
ValidationAppException.toDetails(errors),
);
}
private static toDetails(errors: ValidationError[]) {
return errors.flatMap((error) =>
Object.entries(error.constraints ?? {}).map(([rule, message]) => ({
field: error.property,
rule,
message,
})),
);
}
}Для складних вкладених DTO потрібно рекурсивно обробляти поле children. Важливий сам принцип: перетворити внутрішню структуру class-validator на стабільний формат API, а не передавати її напряму клієнту.
Нижче наведено мінімальний приклад модуля користувачів. Він демонструє:
DTO з валідацією;
контрольовану помилку USER_NOT_FOUND;
глобальний фільтр;
безпечну обробку непередбаченої помилки.
// src/users/create-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { UserNotFoundException } from '../common/errors/user-not-found.exception';
@Injectable()
export class UsersService {
private readonly users = new Map([
['1', { id: '1', email: 'anna@example.com' }],
]);
findOne(id: string) {
const user = this.users.get(id);
if (!user) {
throw new UserNotFoundException(id);
}
return user;
}
create(email: string, password: string) {
// Пароль не зберігається у відповіді та не додається до details.
return {
id: String(this.users.size + 1),
email,
};
}
}// src/users/users.controller.ts
import {
Body,
Controller,
Get,
Param,
Post,
} from '@nestjs/common';
import { CreateUserDto } from './create-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto.email, dto.password);
}
}// src/common/errors/app.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';
export class AppException extends HttpException {
constructor(
public readonly code: string,
status: HttpStatus,
message: string,
public readonly details?: unknown,
) {
super(
{
code,
message,
...(details !== undefined ? { details } : {}),
},
status,
);
}
}// src/common/errors/user-not-found.exception.ts
import { HttpStatus } from '@nestjs/common';
import { AppException } from './app.exception';
export class UserNotFoundException extends AppException {
constructor(userId: string) {
super(
'USER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'Користувача не знайдено',
{ userId },
);
}
}// src/common/errors/validation-app.exception.ts
import {
HttpStatus,
ValidationError,
} from '@nestjs/common';
import { AppException } from './app.exception';
export class ValidationAppException extends AppException {
constructor(errors: ValidationError[]) {
super(
'VALIDATION_ERROR',
HttpStatus.BAD_REQUEST,
'Дані запиту не пройшли перевірку',
errors.flatMap((error) =>
Object.entries(error.constraints ?? {}).map(([rule, message]) => ({
field: error.property,
rule,
message,
})),
),
);
}
}// src/common/errors/error-response.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
Injectable,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { AppException } from './app.exception';
@Injectable()
@Catch()
export class ErrorResponseFilter implements ExceptionFilter {
private readonly logger = new Logger(ErrorResponseFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const context = host.switchToHttp();
const request = context.getRequest();
const response = context.getResponse();
let statusCode = 500;
let code = 'INTERNAL_SERVER_ERROR';
let message = 'Внутрішня помилка сервера';
let details: unknown;
if (exception instanceof AppException) {
statusCode = exception.getStatus();
code = exception.code;
message = exception.message;
details = exception.details;
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
code = `HTTP_${statusCode}`;
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (
exceptionResponse &&
typeof exceptionResponse === 'object' &&
'message' in exceptionResponse
) {
const responseMessage = (exceptionResponse as { message?: unknown })
.message;
if (typeof responseMessage === 'string') {
message = responseMessage;
}
}
} else {
if (exception instanceof Error) {
this.logger.error(exception.message, exception.stack);
} else {
this.logger.error('Невідома помилка без об’єкта Error');
}
}
httpAdapter.reply(
response,
{
statusCode,
code,
message,
...(details !== undefined ? { details } : {}),
path: httpAdapter.getRequestUrl(request),
timestamp: new Date().toISOString(),
},
statusCode,
);
}
}У помилки можуть бути дві категорії даних:
Їх дозволено повертати клієнту:
код помилки;
безпечне повідомлення;
назва поля з помилкою;
допустимі значення;
ідентифікатор запиту;
безпечний ідентифікатор ресурсу.
Їх потрібно залишати тільки в логах:
stack trace;
cause;
SQL-запити;
назви таблиць і колонок;
секрети;
токени;
паролі;
внутрішні шляхи;
повні об'єкти винятків сторонніх бібліотек.
Невдалий приклад:
catch (error) {
throw new AppException(
'DATABASE_ERROR',
HttpStatus.INTERNAL_SERVER_ERROR,
String(error),
);
}Такий код може розкрити текст SQL-запиту або службову інформацію.
Безпечніший підхід:
try {
// Операція, яка може завершитися помилкою.
await repository.save(entity);
} catch (error) {
// Деталі записуються в лог із контрольованим доступом.
logger.error(
'Не вдалося зберегти користувача',
error instanceof Error ? error.stack : undefined,
);
throw new AppException(
'USER_CREATION_FAILED',
HttpStatus.INTERNAL_SERVER_ERROR,
'Не вдалося створити користувача',
);
}Якщо помилка має бути оброблена на вищому рівні, її можна повторно викинути після логування. Не потрібно повертати внутрішній об'єкт помилки в HTTP-відповідь.
Коди мають бути:
стабільними;
однозначними;
незалежними від мови повідомлення;
достатньо конкретними для клієнта;
задокументованими разом з endpoint.
Приклади:
VALIDATION_ERROR
USER_NOT_FOUND
USER_ALREADY_EXISTS
INVALID_CREDENTIALS
ACCESS_DENIED
RESOURCE_CONFLICT
INTERNAL_SERVER_ERRORНе варто будувати клієнтську логіку на порівнянні текстів:
if (error.message === 'Користувача не знайдено') {
// Крихка логіка.
}Краще використовувати код:
if (error.code === 'USER_NOT_FOUND') {
// Стабільна логіка клієнта.
}HTTP-статус та прикладний код виконують різні ролі:
HTTP-статус описує загальний результат для протоколу;
code описує конкретну причину в межах домену застосунку.
Наприклад, кілька помилок можуть мати статус 409 Conflict:
USER_ALREADY_EXISTS
EMAIL_ALREADY_VERIFIED
ORDER_ALREADY_CANCELLEDТому одного HTTP-статусу недостатньо для коректної поведінки клієнта.
Водночас не слід створювати окремий прикладний код для кожної незначної варіації повідомлення. Код має описувати стабільну категорію помилки, а не конкретний текст.
Виняток відповідає на питання:
Що сталося?
Фільтр відповідає на питання:
Як перетворити це на зовнішню HTTP-відповідь?
Наприклад, сервіс не повинен знати, як саме форматуються JSON-відповіді:
throw new UserNotFoundException(userId);Сервіс створює доменну помилку, а глобальний фільтр додає до неї:
HTTP-статус;
URL запиту;
час;
фінальну структуру відповіді.
Це не дозволяє контролерам і сервісам дублювати форматування помилок.
Невдалий варіант:
return {
statusCode: 404,
message: 'Користувача не знайдено',
};У цьому випадку NestJS може повернути HTTP-статус 200, якщо його не змінити окремо. Клієнт побачить помилку лише за вмістом JSON, що суперечить HTTP-контракту.
Правильно:
throw new UserNotFoundException(userId);Невдалий варіант:
throw new HttpException(error, 500);Об'єкт error може містити внутрішні поля та чутливі дані. Публічна відповідь має формуватися явно.
Stack trace корисний для розробника, але небезпечний для клієнта. Його потрібно логувати на сервері, а не додавати до details.
Повідомлення змінюються через локалізацію, редагування або вимоги продукту. Код помилки має залишатися стабільним.
new, якщо йому потрібні залежностіНевдалий варіант:
app.useGlobalFilters(new ErrorResponseFilter());У такому разі NestJS не зможе передати фільтру HttpAdapterHost або інші залежності.
Якщо фільтр має залежності, використовуйте provider і отримуйте його через контейнер:
app.useGlobalFilters(app.get(ErrorResponseFilter));detailsНавіть поле details не повинно автоматично містити довільні дані. Перед додаванням перевіряйте, чи справді вони потрібні клієнту та чи не містять секретів або персональних даних.
Для узгодженої архітектури помилок:
Створіть базовий AppException.
Визначте перелік стабільних кодів помилок.
Використовуйте спеціалізовані класи для важливих доменних помилок.
Перетворюйте помилки валідації у власний формат.
Зареєструйте один глобальний exception filter.
Для невідомих помилок повертайте загальне повідомлення.
Повний stack trace записуйте тільки в серверні логи.
Додавайте details лише для безпечних і потрібних даних.
Не будуйте клієнтську логіку на тексті повідомлення.
Вважайте структуру помилки частиною контракту API.
Узгоджена архітектура помилок у NestJS складається з трьох рівнів:
винятки описують причину проблеми;
глобальний фільтр формує єдину HTTP-відповідь;
логування зберігає внутрішні деталі для діагностики.
Клієнт повинен отримувати стабільні поля statusCode, code і message, а додаткові details — лише тоді, коли вони безпечні. Непередбачені помилки не можна розкривати зовні, але їх потрібно повністю фіксувати на сервері.