Що таке interceptors у NestJS?
NestJS interceptor (перехоплювач) - клас, що обгортає виконання обробника маршруту через RxJS Observable і дозволяє запускати логіку як до хендлера, так і після нього.
Теорія
Коротко
- Interceptors стоять у pipeline після guards і охоплюють хендлер з обох боків
next.handle()повертаєObservable; все всередині.pipe()запускається після хендлера- Аналогія: митний контроль в аеропорту, що перевіряє багаж до посадки і після приземлення
- Використовуй для логування, стандартизації відповідей, кешування та маппінгу помилок
- Не замінює guards (авторизація) і pipes (валідація вхідних даних)
Швидкий приклад
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
// На рівні методу
@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()
// Неправильно
return next.handle().then(data => ({ wrapped: data })); // TypeError під час виконанняnext.handle() повертає Observable, а не Promise. Використовуй .pipe(map(...)). Це найпоширеніша помилка в NestJS interceptors.
Помилка 2: tap() для обробки помилок
// Неправильно - помилки хендлера не доходять до tap()
return next.handle().pipe(
tap(() => { throw new Error('boom'); }),
);tap спрацьовує тільки на успішних emission-ах. Помилки хендлера його обходять. Для будь-якої логіки на шляху помилки використовуй catchError, інакше виробничі помилки не будуть залоговані.
Помилка 3: Випадкове ігнорування хендлера
// Забули повернути 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
// Неправильна анотація
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) → глобальний
catchErrorinterceptor до того, як помилки дійдуть до 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 не завершився за вказані мілісекунди.
Приклади
Базовий: логування запитів
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
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.
Просунутий: кешування з коротким замиканням
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-механізмом поверх цього паттерну.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.