Що таке фільтри винятків у NestJS?
Фільтри винятків (exception filters) у NestJS - це класи, які перехоплюють необроблені винятки до того, як відповідь покине сервер, і дають повний контроль над статус-кодом, тілом відповіді та побічними ефектами на кшталт логування.
Теорія
Коротко
- Уяви поштовий сортувальник: він ловить "проблемні листи" (кинуті помилки) і вирішує, що відправити натомість, а не відпускає їх безладно
- Вбудований глобальний фільтр обробляє
HttpExceptionавтоматично; кастомні фільтри дозволяють цілитись на конкретні типи, додавати логування або змінювати структуру відповіді @Catch(HttpException)охоплює тільки NestJS HTTP-винятки;@Catch()без аргументів перехоплює все, включно з сиримError- Глобальна реєстрація через
APP_FILTERу модулі підтримує DI;app.useGlobalFilters()уmain.ts- без DI - Фільтри спрацьовують після того, як виняток кинутий, але до відправки відповіді
Швидкий приклад
// http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException) // охоплює HttpException та всі підкласи
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.getResponse(),
});
}
}Будь-який HttpException тепер повертає однакову JSON-структуру з часом та шляхом запиту. Стандартний обробник NestJS для цих типів більше не спрацьовує.
Ключова різниця
NestJS має вбудований глобальний обробник, який форматує базову JSON-відповідь для підкласів HttpException. Кастомні фільтри перевизначають або розширюють цю поведінку: реалізуєш інтерфейс ExceptionFilter і через @Catch() вказуєш, які типи обробляти. Головна перевага - одне місце для логування, виклику Sentry або додавання власних полів без змін у кожному контролері. Це краще за try/catch блоки розкидані по всіх хендлерах.
Коли використовувати
- Єдиний формат помилок для всього додатку: глобальний фільтр через
APP_FILTER - Поведінка для конкретного маршруту (наприклад, помилки
/adminйдуть у Slack):@UseFilters()на контролері або методі - Тільки певні типи винятків потребують особливої обробки:
@Catch(BadRequestException)або@Catch(ValidationError) - Побічні ефекти на кшталт Sentry чи Prometheus без зміни бізнес-логіки: додай їх усередину
catch() - Страхувальна сітка для необроблених помилок:
@Catch()без аргументів відображає все у 500
Як NestJS обробляє фільтри всередині
Коли виняток "спливає" з контролера, NestJS перевіряє метадані декораторів @Catch(), зареєстровані при старті. Перший фільтр, чий аргумент типу збігається з кинутим винятком, запускає catch(). Усередині ArgumentsHost надає доступ до об'єктів Request і Response від Express. Якщо жоден кастомний фільтр не підходить, стандартний BaseExceptionFilter обробляє екземпляри HttpException.
Порядок при кількох глобальних фільтрах є зворотнім до порядку реєстрації. Останній зареєстрований провайдер APP_FILTER спрацьовує першим для відповідного типу. Я з'ясував це на власному досвіді: фільтр-логер не виконувався до відправки відповіді, і виявилось, що широкі фільтри треба реєструвати останніми.
Типові помилки
-
Асинхронний
catch(). NestJS очікує синхронних фільтрів. Асинхроннийcatch()зawaitможе залишити відповідь у підвішеному стані.typescript// Неправильно - відповідь зависає async catch(exception: HttpException, host: ArgumentsHost) { await sendToSlack(exception.message); // відповідь не відправляється } // Правильно - fire and forget для асинхронних ефектів catch(exception: HttpException, host: ArgumentsHost) { setImmediate(() => sendToSlack(exception.message)); // відповідь записуємо синхронно нижче } -
Відсутність
multi: trueпри ланцюжку глобальних фільтрів. Без нього один провайдерAPP_FILTERможе замінити вбудований обробник, залишивши деякі типи помилок без запасного варіанта.typescript// Неправильно { provide: APP_FILTER, useClass: MyFilter } // Правильно { provide: APP_FILTER, useClass: MyFilter, multi: true } -
@Catch()без аргументів скрізь. Він перехоплює іTypeError, іReferenceError, маскуючи реальні баги розробника як загальні 500-ті.typescript// Ризиковано - TypeError зникає у 500 @Catch() // Безпечніше для більшості фільтрів @Catch(HttpException) -
Очікування стекування
@UseFilters()на рівні контролера і методу. Фільтр рівня методу перевизначає фільтр рівня контролера для цього endpoint. Вони не стекуються.
Де зустрічається в реальних проектах
- NestJS + Prisma: перехоплює
PrismaClientKnownRequestErrorз кодомP2002і повертає409 Conflictз назвою поля-дубліката - Sentry через
nestjs-sentry: фільтр викликаєSentry.captureException()перед відправкою відповіді - Валідація з
class-validator: перехоплюєBadRequestException, витягує помилки по полях, повертає структурований масивerrors - GraphQL API:
@Catch(ApolloError)формує відповіді з помилками для GQL окремо від REST - Мікросервісний gateway: логування і трейсинг у gateway; специфічні для домену деталі помилок у сервісах
Питання для співбесіди
Q: Як застосувати фільтр тільки до одного методу?
A: Додай @UseFilters(MyFilter) безпосередньо на метод. Він перевизначить будь-який фільтр на рівні контролера або глобальний для цього конкретного endpoint.
Q: У чому різниця між @Catch(HttpException) і @Catch()?
A: @Catch(HttpException) перехоплює тільки NestJS HTTP-винятки, дозволяючи сирим Error проходити до вбудованого обробника. @Catch() без аргументів ловить все, включно з TypeError і ReferenceError.
Q: Чи може фільтр отримати доступ до даних, встановлених guard або interceptor?
A: Ні. Фільтри спрацьовують у фазі помилки після того, як ланцюжок виконання вже завершився. Через ArgumentsHost доступні тільки дані запиту. Контекст guard або interceptor на цьому етапі недоступний.
Q: Як працює порядок фільтрів при кількох глобальних реєстраціях?
A: NestJS інвертує порядок реєстрації під час виконання. Останній зареєстрований провайдер APP_FILTER спрацьовує першим для відповідного типу. Широкі фільтри реєструй останніми, інакше вони перекриють типізовані.
Q: У мікросервісному сетапі з gateway, де розміщувати фільтри?
A: Специфічні для домену фільтри (наприклад, NotFoundException з деталями сутності) - у конкретних сервісах. Наскрізні фільтри для логування, алертингу або трейсингу - у gateway. Так сервіси залишаються незалежними, а gateway відповідає за observability.
Приклади
Базовий: глобальний фільтр для всіх винятків
Гарна відправна точка для продакшн-додатків. Перехоплює будь-яке кинуте значення і повертає однакову JSON-структуру з логуванням.
// all-exceptions.filter.ts
import {
ExceptionFilter, Catch, ArgumentsHost,
HttpException, HttpStatus, Logger
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
this.logger.error(`${request.method} ${request.url}`, String(exception));
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// app.module.ts
@Module({
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
export class AppModule {}Кожен необроблений виняток тепер повертає { statusCode, message, timestamp, path }. Реєстрація через APP_FILTER замість useGlobalFilters() зберігає роботу dependency injection всередині фільтра.
Середній: фільтр валідації для signup endpoint
Реальний сценарій з auth-флоу. class-validator кидає BadRequestException з масивом помилок по полях усередині тіла відповіді. Цей фільтр витягує їх у зручну структуру.
// validation.filter.ts
import { Catch, ArgumentsHost, BadRequestException } from '@nestjs/common';
import { Response, Request } from 'express';
@Catch(BadRequestException)
export class ValidationFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const body = exception.getResponse() as any;
response.status(400).json({
statusCode: 400,
message: 'Validation failed',
errors: body.message, // масив: ['email must be an email']
path: request.url,
});
}
}
// auth.controller.ts
@Controller('auth')
@UseFilters(ValidationFilter)
export class AuthController {
@Post('signup')
signup(@Body() dto: CreateUserDto) {
return this.authService.signup(dto);
}
}POST /auth/signup з невалідним email тепер повертає { statusCode: 400, message: "Validation failed", errors: ["email must be an email"] } замість стандартного формату NestJS.
Просунутий: маппінг помилок Prisma
Перехоплення порушень обмежень на рівні бази даних і перетворення їх у правильні HTTP-відповіді. Зустрічається в будь-якому проекті NestJS + Prisma.
// prisma-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Response, Request } from 'express';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// P2002 = порушення обмеження унікальності
const status = exception.code === 'P2002'
? HttpStatus.CONFLICT
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception.code === 'P2002'
? `Duplicate value on: ${(exception.meta?.target as string[])?.join(', ')}`
: 'Database error';
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}POST /users з дублікатом email тепер не повертає сирий стек-трейс Prisma або загальний 500. Клієнт отримує { statusCode: 409, message: "Duplicate value on: email" }. Реєструй цей фільтр глобально або на рівні модуля залежно від того, де використовується Prisma.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.