Skip to main content

Що таке фільтри винятків у NestJS?

Фільтри винятків (exception filters) у NestJS - це класи, які перехоплюють необроблені винятки до того, як відповідь покине сервер, і дають повний контроль над статус-кодом, тілом відповіді та побічними ефектами на кшталт логування.

Теорія

Коротко

  • Уяви поштовий сортувальник: він ловить "проблемні листи" (кинуті помилки) і вирішує, що відправити натомість, а не відпускає їх безладно
  • Вбудований глобальний фільтр обробляє HttpException автоматично; кастомні фільтри дозволяють цілитись на конкретні типи, додавати логування або змінювати структуру відповіді
  • @Catch(HttpException) охоплює тільки NestJS HTTP-винятки; @Catch() без аргументів перехоплює все, включно з сирим Error
  • Глобальна реєстрація через APP_FILTER у модулі підтримує DI; app.useGlobalFilters() у main.ts - без DI
  • Фільтри спрацьовують після того, як виняток кинутий, але до відправки відповіді

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

typescript
// 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 спрацьовує першим для відповідного типу. Я з'ясував це на власному досвіді: фільтр-логер не виконувався до відправки відповіді, і виявилось, що широкі фільтри треба реєструвати останніми.

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

  1. Асинхронний 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)); // відповідь записуємо синхронно нижче }
  2. Відсутність multi: true при ланцюжку глобальних фільтрів. Без нього один провайдер APP_FILTER може замінити вбудований обробник, залишивши деякі типи помилок без запасного варіанта.

    typescript
    // Неправильно { provide: APP_FILTER, useClass: MyFilter } // Правильно { provide: APP_FILTER, useClass: MyFilter, multi: true }
  3. @Catch() без аргументів скрізь. Він перехоплює і TypeError, і ReferenceError, маскуючи реальні баги розробника як загальні 500-ті.

    typescript
    // Ризиковано - TypeError зникає у 500 @Catch() // Безпечніше для більшості фільтрів @Catch(HttpException)
  4. Очікування стекування @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-структуру з логуванням.

typescript
// 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 з масивом помилок по полях усередині тіла відповіді. Цей фільтр витягує їх у зручну структуру.

typescript
// 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.

typescript
// 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.

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

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

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

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