Skip to main content

Що таке interceptors у NestJS?

NestJS interceptor (перехоплювач) - клас, що обгортає виконання обробника маршруту через RxJS Observable і дозволяє запускати логіку як до хендлера, так і після нього.

Теорія

Коротко

  • Interceptors стоять у pipeline після guards і охоплюють хендлер з обох боків
  • next.handle() повертає Observable; все всередині .pipe() запускається після хендлера
  • Аналогія: митний контроль в аеропорту, що перевіряє багаж до посадки і після приземлення
  • Використовуй для логування, стандартизації відповідей, кешування та маппінгу помилок
  • Не замінює guards (авторизація) і pipes (валідація вхідних даних)

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

typescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { console.log('До хендлера'); const now = Date.now(); return next.handle().pipe( tap(() => console.log(`Після хендлера. Час: ${Date.now() - now}ms`)), ); } }

Код до next.handle() виконується першим. Колбек у tap запускається після того, як хендлер завершився. Ця межа і є вся модель.

Головна відмінність від middleware

Express middleware працює з сирими об'єктами req і res у лінійному ланцюгу до хендлера. Interceptors обгортають Observable самого хендлера, тому ти отримуєш контроль над асинхронним потоком: retry, timeout, catchError - все це компонується через стандартні RxJS оператори. Логіка після хендлера тут чистіша, бо ти реагуєш на те, що хендлер насправді повернув.

Коли використовувати

  • Стандартна обгортка відповіді → interceptor (додати { data, success, timestamp } до кожного endpoint)
  • Логування часу виконання по всіх маршрутах → interceptor
  • Коротке замикання при попаданні в кеш → interceptor (of(cached) пропускає хендлер)
  • Маппінг типів помилок (помилка БД → HTTP 503) → interceptor із catchError
  • Перевірка авторизації → guard (запускається до interceptors, може відхилити запит раніше)
  • Валідація вхідних даних → pipe

Як це працює всередині

Коли приходить запит, NestJS через reflection metadata збирає ланцюг interceptors і викликає кожен intercept() у порядку реєстрації. CallHandler.handle() при підписці запускає сам хендлер маршруту. Твій .pipe() стоїть поверх цього Observable. Помилки передаються через throwError(), тому catchError їх перехоплює, а tap - ні.

Глобальні interceptors, зареєстровані через APP_INTERCEPTOR, запускаються в тому порядку, в якому вони описані в providers. Важливо, якщо у тебе є transform-interceptor і logging-interceptor і порядок впливає на те, що потрапляє в логи.

Як підключити interceptor

typescript
// На рівні методу @Get() @UseInterceptors(LoggingInterceptor) findAll() { ... } // На рівні контролера @Controller('users') @UseInterceptors(TransformInterceptor) export class UsersController {} // Глобально через модуль (підтримує DI) @Module({ providers: [ { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, ], }) export class AppModule {}

Місце в pipeline запиту

Запит → Middleware → Guards → Interceptors (до хендлера) → Pipes → Route Handler → Interceptors (після хендлера) → Exception Filters Відповідь

Поширені помилки

Помилка 1: Виклик .then() на next.handle()

typescript
// Неправильно return next.handle().then(data => ({ wrapped: data })); // TypeError під час виконання

next.handle() повертає Observable, а не Promise. Використовуй .pipe(map(...)). Це найпоширеніша помилка в NestJS interceptors.

Помилка 2: tap() для обробки помилок

typescript
// Неправильно - помилки хендлера не доходять до tap() return next.handle().pipe( tap(() => { throw new Error('boom'); }), );

tap спрацьовує тільки на успішних emission-ах. Помилки хендлера його обходять. Для будь-якої логіки на шляху помилки використовуй catchError, інакше виробничі помилки не будуть залоговані.

Помилка 3: Випадкове ігнорування хендлера

typescript
// Забули повернути next.handle() при промаху кешу async intercept(ctx: ExecutionContext, next: CallHandler) { const cached = await this.cache.get(key); if (cached) return of(cached); // забули: return next.handle().pipe(...) }

Результат - undefined для всіх запитів без кешу. Обов'язково перевір, що обидві гілки щось повертають.

Помилка 4: Неправильний тип повернення для async interceptor

typescript
// Неправильна анотація async intercept(ctx, next): Observable<any> { ... } // TypeScript виведе помилку // Правильно async intercept(ctx, next): Promise<Observable<any>> { ... }

Як тільки додаєш async до intercept(), тип повернення стає Promise<Observable<any>>, а не Observable<any>. TypeScript це зловить, але новачки, які вперше поєднують async/await з Observable, регулярно на це натрапляють.

Де зустрічається в реальних проєктах

  • NestJS boilerplates → TransformInterceptor для консистентного конверту { success: true, data } на кожному endpoint
  • Prisma + NestJS → interceptor для вимірювання часу запитів і виявлення повільних місць
  • BullMQ job processors → глобальний interceptor для нормалізації формату результатів задач
  • Error monitoring (Sentry, Datadog) → глобальний catchError interceptor до того, як помилки дійдуть до exception filters

Питання на співбесіді

Q: Де в pipeline стоять interceptors відносно guards і pipes?
A: Після guards на шляху до хендлера і після хендлера на шляху назад. Guard може відхилити запит до того, як interceptor взагалі запуститься. Pipes йдуть між interceptors і хендлером.

Q: Навіщо тут RxJS, а не звичайний async/await?
A: Observables дозволяють декларативно компонувати retry, timeout і обробку помилок. З колбеками все це треба дротити вручну. Оператор timeout сам по собі окупає використання RxJS в будь-якому продакшн API.

Q: Як застосувати interceptor тільки до POST-запитів?
A: Перевір context.switchToHttp().getRequest().method всередині intercept() і умовно виклич next.handle(). Або використай @UseInterceptors() лише на методі з декоратором @Post().

Q: Яке навантаження від глобального interceptor?
A: Приблизно 1-2ms на запит для типових transform або logging операцій. Поміряй timing-interceptором, перш ніж вважати це проблемою.

Q (senior): Реалізуй timeout interceptor для повільних запитів до БД.
A: return next.handle().pipe(timeout(5000), catchError(() => { throw new HttpException('Request timeout', 408); })). Оператор timeout з RxJS кидає помилку, якщо Observable не завершився за вказані мілісекунди.

Приклади

Базовий: логування запитів

typescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const req = context.switchToHttp().getRequest(); const now = Date.now(); return next.handle().pipe( tap(() => { console.log(`${req.method} ${req.url} - ${Date.now() - now}ms`); }), ); } }

Підключи глобально через { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }. Кожен маршрут буде логувати метод, шлях і час виконання після повернення хендлера - без жодних змін в контролерах.

Середній рівень: стандартизація відповідей API

typescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; interface Response<T> { data: T; success: boolean; timestamp: string; } @Injectable() export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> { intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> { return next.handle().pipe( map((data) => ({ data, success: true, timestamp: new Date().toISOString(), })), ); } } // GET /products // До: [{ id: 1, name: 'Клавіатура' }] // Після: { data: [{ id: 1, name: 'Клавіатура' }], success: true, timestamp: "2024-..." }

Підключи глобально - і кожен endpoint повертає консистентну структуру. Фронтенд завжди читає response.data без обробки різних форматів по кожному endpoint.

Просунутий: кешування з коротким замиканням

typescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable, of } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { HttpException, HttpStatus } from '@nestjs/common'; import { Cache } from 'cache-manager'; @Injectable() export class CacheInterceptor implements NestInterceptor { constructor(private cacheManager: Cache) {} async intercept( context: ExecutionContext, next: CallHandler, ): Promise<Observable<any>> { const key = context.switchToHttp().getRequest().url; const cached = await this.cacheManager.get(key); if (cached) return of(cached); // хендлер не запускається return next.handle().pipe( tap(async (data) => { await this.cacheManager.set(key, data, 300); // TTL 5 хвилин }), catchError(() => { throw new HttpException('Service unavailable', HttpStatus.SERVICE_UNAVAILABLE); }), ); } }

Коли of(cached) повертає результат, хендлер маршруту взагалі не викликається. При промаху хендлер виконується, зберігає результат і повертає його. Є один нюанс: паралельні запити, що приходять до заповнення кешу, всі підуть у БД одночасно. Це проблема cache stampede, і вирішується вона окремим lock-механізмом поверх цього паттерну.

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

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

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

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