NestJS course Β· Module 2: Routing and Request Lifecycle

Interceptors - the courier who comes back with an answer

6 min read
In this lesson6

The sentry at the outer post counts those coming in. The treasury guard decides who may enter. Both end their duty at the same moment - when the visitor crosses the threshold. Neither sees what he walks out with.

The courier does. He rides to the governor with an order, waits for the reply, and brings it back - and along the way he may add something to it. An interceptor is the first mechanism of the request cycle that also runs code after the handler has finished, and that single sentence explains almost everything it does.

The interface and the signature

An interceptor is a class implementing the NestInterceptor interface. Not CanActivate - that belongs to the guards of the previous lesson; not PipeTransform - those are pipes, which come right after this lesson; not ExceptionFilter - that one deals with catching errors. The interface calls for a single method:

1import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
2import { Observable } from 'rxjs';
3
4@Injectable()
5export class AuditInterceptor implements NestInterceptor {
6  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
7    return next.handle();
8  }
9}

The signature has three parts written in this order: intercept( opens the method, context: ExecutionContext, is the first argument, and next: CallHandler) the second. The interceptor above does nothing yet - it passes the request on and hands back the result untouched.

You already know ExecutionContext from guards: it is the same object, with the same getHandler() and switchToHttp() methods. What is new is CallHandler - a handle to the rest of the chain. Until you call handle() on it, the controller's handler does not run at all.

Four phases

An interceptor's work divides into four stages, always in this order:

  1. Calling the intercept() method.
  2. Logic before passing the request (before).
  3. Executing the route handler (next.handle()).
  4. Response processing (after).

The division is plain to the eye in the code, and one boundary is all you need to remember: everything you write before return next.handle() is the before phase; everything that goes into .pipe(...) is the after phase. A single method covers both legs of the courier's journey - the ride out and the ride home.

That is exactly why an interceptor can measure how long a request took, which neither middleware nor a guard can do: it records a mark in the before phase and reads it in the after phase, within one method and off the same variable.

tap - acting without changing the result

next.handle() does not return finished data but an Observable - a stream from the RxJS library into which the response will arrive. Operations on the stream are attached with the .pipe() method, and the operator most commonly used to execute logic after receiving a response is tap:

1@Injectable()
2export class LoggingInterceptor implements NestInterceptor {
3  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4    const start = Date.now();
5
6    return next.handle().pipe(
7      tap(() => console.log(`Time: ${Date.now() - start}ms`)),
8    );
9  }
10}

const start = Date.now() runs in the before phase, the function inside tap in the after phase, once the response is back. The difference between the two readings is the time the request took.

The name tap comes from wiretapping: the operator looks at the value flowing past and lets it through unchanged. That is precisely what we want for logging - a client's response must not depend on whether we happen to be writing to the console. Its three neighbours in RxJS do something else: map replaces the value, filter can hold it back, and reduce folds a whole stream into one value and waits for it to end - in an HTTP request there is a single value, so there is nothing to fold.

map - changing the shape of the response

When the response really is meant to look different, you reach for map. The typical use is a common envelope for every endpoint:

1@Injectable()
2export class TransformInterceptor implements NestInterceptor {
3  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4    return next.handle().pipe(
5      map((data) => ({ success: true, data })),
6    );
7  }
8}

The controller returns a list of tributes and the client receives { success: true, data: [...] } - and the same at every other endpoint, without adding a single line to any controller. The difference from the previous example fits in one sentence: tap watches, map replaces. If the function in tap returns something, it is ignored; what the function in map returns becomes the response.

RxJS has more such operators, and some are useful in interceptors straight away: catchError catches an exception thrown by the handler, timeout cuts off a request that takes too long. All of them go into the same .pipe().

Attaching it

An interceptor is attached with the @UseInterceptors decorator - on a method or on a whole controller:

1@Controller('tributes')
2@UseInterceptors(LoggingInterceptor)
3export class TributesController {
4  @Get()
5  findAll() {
6    return this.tributesService.findAll();
7  }
8}

You pass the class, not an instance of it - creation is left to dependency injection, which is what lets an interceptor take its own dependencies in the constructor. Several interceptors separated by commas form a chain: the before phase runs in the order written, the after phase in reverse, because the stream comes home the way it went.

When the envelope is to cover the whole application, you register the interceptor globally: app.useGlobalInterceptors(new TransformInterceptor()) in main.ts, or as a provider with the APP_INTERCEPTOR token. The second road is the better one wherever the interceptor needs something itself - a provider goes through dependency injection, new does not.

Summary

The courier rides out and comes back:

  • an interceptor implements the NestInterceptor interface - not CanActivate, not PipeTransform, not ExceptionFilter,
  • the signature in order: intercept( β†’ context: ExecutionContext, β†’ next: CallHandler),
  • four phases: calling intercept() β†’ logic before passing the request (before) β†’ executing the route handler (next.handle()) β†’ response processing (after),
  • the boundary is simple: before return next.handle() is the before phase, inside .pipe(...) is the after phase,
  • next.handle() returns an Observable; without calling it the handler never runs,
  • tap is the operator most commonly used for logic after receiving a response - it watches the value and lets it through unchanged, unlike map (replaces), filter (holds back) and reduce (folds the stream),
  • timing: const start = Date.now() in the before phase, tap(() => ...) with a second Date.now() in the after phase,
  • map((data) => ({ success: true, data })) wraps the response in a common envelope,
  • attached with @UseInterceptors(LoggingInterceptor), globally through useGlobalInterceptors or the APP_INTERCEPTOR token.

In the next lesson you will meet pipes - the fourth and last mechanism of the cycle, which takes on the individual arguments of a method before it runs at all. For now remember: middleware and guards work on the way in; the interceptor alone rides both ways.

Code for this lesson: src/interceptors.ts
1// Interceptors in NestJS - Couriers and Diplomats of the Empire
2import {
3  Injectable, NestInterceptor, ExecutionContext, CallHandler,
4} from '@nestjs/common';
5import { Observable } from 'rxjs';
6import { tap, map, catchError } from 'rxjs/operators';
7
8console.log("Interceptors - intercepting and modifying communication!");
9
10// ===========================================
11// 1. LoggingInterceptor - Chronicler
12// ===========================================
13
14@Injectable()
15export class LoggingInterceptor implements NestInterceptor {
16  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
17    const request = context.switchToHttp().getRequest();
18    const method = request.method;
19    const url = request.url;
20    const now = Date.now();
21
22    console.log('[Before] ' + method + ' ' + url);
23
24    return next.handle().pipe(
25      tap(() => {
26        const duration = Date.now() - now;
27        console.log('[After] ' + method + ' ' + url + ' - ' + duration + 'ms');
28      }),
29    );
30  }
31}
32
33// ===========================================
34// 2. TransformInterceptor - Response formatting
35// ===========================================
36
37@Injectable()
38export class TransformInterceptor implements NestInterceptor {
39  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
40    return next.handle().pipe(
41      map(data => ({
42        success: true,
43        data,
44        timestamp: new Date().toISOString(),
45        imperium: 'Roma Aeterna',
46      })),
47    );
48  }
49}
50
51// ===========================================
52// 3. CacheInterceptor - Cache memory
53// ===========================================
54
55@Injectable()
56export class CacheInterceptor implements NestInterceptor {
57  private cache = new Map<string, { data: any; expiry: number }>();
58
59  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
60    const request = context.switchToHttp().getRequest();
61    const cacheKey = request.url;
62    const cached = this.cache.get(cacheKey);
63
64    if (cached && cached.expiry > Date.now()) {
65      console.log('[Cache] Hit: ' + cacheKey);
66      return new Observable(subscriber => {
67        subscriber.next(cached.data);
68        subscriber.complete();
69      });
70    }
71
72    console.log('[Cache] Miss: ' + cacheKey);
73    return next.handle().pipe(
74      tap(data => {
75        this.cache.set(cacheKey, {
76          data,
77          expiry: Date.now() + 60000, // 60 seconds
78        });
79      }),
80    );
81  }
82}
83
84// ===========================================
85// 4. ErrorInterceptor - Error handling
86// ===========================================
87
88@Injectable()
89export class ErrorInterceptor implements NestInterceptor {
90  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
91    return next.handle().pipe(
92      catchError(err => {
93        console.error('[Error] ' + err.message);
94        throw err;
95      }),
96    );
97  }
98}
99
100console.log("\n=== INTERCEPTORS SUMMARY ===");
101console.log("NestInterceptor - interceptor interface");
102console.log("next.handle() - handler invocation (Observable)");
103console.log("tap() - side effects (logging)");
104console.log("map() - response transformation");
105console.log("catchError() - error catching");
106

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What interface does an interceptor implement in NestJS?

  2. 2. Which RxJS operator is most commonly used in an interceptor to execute logic after receiving a response?

Hands-on tasks in the game

  • Vertical ordering

    Order the execution phases of an interceptor in NestJS:

  • Code editor

    Write a LoggingInterceptor that logs request execution time using Date.now() before and after next.handle()

  • Horizontal ordering

    Arrange the elements of the intercept method signature in the correct order:

  • Code editor

    Write a TransformInterceptor that uses map() from RxJS to wrap the response in { success: true, data: response }

Useful articles