NestJS course Β· Module 2: Routing and Request Lifecycle

Exception Filters - courts and tribunals of the empire

7 min read
In this lesson6

A provincial asks about a tribute that is not in the register, and the server answers with status 500 and the message "Internal server error". The client cannot tell whether it made a mistake or the database went down, and you do not know what you actually sent. Praetor Augustus, the highest judge of Rome, keeps repeating that every case must reach the right tribunal and end with a clear verdict. In NestJS those tribunals are exception filters - classes that catch exceptions and turn them into HTTP responses.

What are Exception Filters?

In the Roman Empire every kind of case had its own tribunal:

  • Praetorian tribunal - civil cases (404 Not Found)
  • Censor's court - access control (403 Forbidden)
  • Military tribunal - serious violations (500 Internal Error)

NestJS has a built-in exceptions layer that acts as the default tribunal: it catches every unhandled exception. You write your own filter when you want to change the wording of the verdict.

Built-in NestJS exceptions

Before you build your own court, get to know the ready-made verdicts. All the classes below extend HttpException and set the right status code on their own:

1import {
2  BadRequestException,      // 400
3  UnauthorizedException,     // 401
4  ForbiddenException,        // 403
5  NotFoundException,         // 404
6  ConflictException,         // 409
7  InternalServerErrorException, // 500
8  HttpException,             // base HTTP exception
9} from '@nestjs/common';

The comments next to the imports give the code the client will receive. Now we throw these exceptions in the tribute controller:

1@Controller('tributa')
2export class TributeController {
3  private tributes = [
4    { id: 1, name: 'Aurum Galliae', amount: 50000 },
5    { id: 2, name: 'Argentum Hispaniae', amount: 30000 },
6  ];
7
8  @Get(':id')
9  findTribute(@Param('id', ParseIntPipe) id: number) {
10    const tribute = this.tributes.find(t => t.id === id);
11    if (!tribute) {
12      // Throw 404 exception
13      throw new NotFoundException(
14        `Tributum ${id} non inventum! (Tribute not found)`
15      );
16    }
17    return tribute;
18  }
19
20  @Post()
21  @UseGuards(RolesGuard)
22  createTribute(@Body() dto: any) {
23    if (!dto.name || !dto.amount) {
24      // Throw 400 exception
25      throw new BadRequestException(
26        'Tribute must contain a name and amount!'
27      );
28    }
29
30    const exists = this.tributes.find(t => t.name === dto.name);
31    if (exists) {
32      // Throw 409 exception
33      throw new ConflictException(
34        `Tribute named "${dto.name}" already exists!`
35      );
36    }
37
38    const newTribute = { id: this.tributes.length + 1, ...dto };
39    this.tributes.push(newTribute);
40    return newTribute;
41  }
42}

The throw keyword stops the method, and the NestJS exceptions layer sends the response for you. For a missing tribute the client gets this JSON:

1{
2  "message": "Tributum 3 non inventum! (Tribute not found)",
3  "error": "Not Found",
4  "statusCode": 404
5}

Your text lands in the message field, while error and statusCode come from the exception class. When you need a code without a dedicated class, reach for the base HttpException with a value from the HttpStatus enum:

1throw new HttpException('Error message', HttpStatus.I_AM_A_TEAPOT); // 418

Status 418 really exists, even though it was born as a joke. Beware of a plain throw new Error(): such an exception is not an HttpException, so the client only sees { "statusCode": 500, "message": "Internal server error" }. The error details stay on the server - and that is a good thing.

Creating custom Exception Filters

Your own verdict format comes from a class that implements the ExceptionFilter interface. The @Catch(HttpException) decorator says which cases go to this court, and the catch() method receives the exception and an ArgumentsHost - the object from which you take the request and the response:

1// filters/roman-exception.filter.ts
2import {
3  ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
4} from '@nestjs/common';
5import { Request, Response } from 'express';
6
7@Catch(HttpException)
8export class RomanExceptionFilter implements ExceptionFilter {
9  catch(exception: HttpException, host: ArgumentsHost) {
10    const ctx = host.switchToHttp();
11    const response = ctx.getResponse<Response>();
12    const request = ctx.getRequest<Request>();
13    const status = exception.getStatus();
14
15    // Custom error response format in empire style
16    response.status(status).json({
17      statusCode: status,
18      imperium: 'Roma Aeterna',
19      error: exception.message,
20      path: request.url,
21      method: request.method,
22      timestamp: new Date().toISOString(),
23    });
24  }
25}

First host.switchToHttp(), then the response and the request from that context, and finally sending the response. You can read the status from the exception (exception.getStatus()) anywhere before sending. This filter only changes the shape of the response - it copies the status code from the exception unchanged. One trap: for validation errors exception.message is the generic "Bad Request Exception", and the list of field errors sits in exception.getResponse().

Binding filters - tribunal levels

You attach a filter with the @UseFilters() decorator to a method or a controller, or globally for the whole application:

1// 1. Method level - only this method
2@Controller('tributa')
3export class TributeController {
4  @Get(':id')
5  @UseFilters(new RomanExceptionFilter()) // Only for this method
6  findTribute(@Param('id') id: string) {
7    throw new NotFoundException('Tribute not found!');
8  }
9}
10
11// 2. Controller level - entire controller
12@Controller('legiones')
13@UseFilters(RomanExceptionFilter) // Entire controller
14export class LegionController {
15  @Get(':id')
16  findLegion(@Param('id') id: string) {
17    throw new NotFoundException('Legion not found!');
18  }
19}
20
21// 3. Global level - entire application (in main.ts)
22// app.useGlobalFilters(new RomanExceptionFilter());

You can pass an instance (new RomanExceptionFilter()) or the class itself. The NestJS documentation recommends the class, because the framework can then reuse one instance across the whole module and inject its dependencies. app.useGlobalFilters() has two limitations: the filter is created outside the module system, so it gets no constructor dependencies, and it does not cover WebSocket gateways. That is why I prefer registering a global filter as a provider:

1// app.module.ts - global filter registered as a provider
2import { Module } from '@nestjs/common';
3import { APP_FILTER } from '@nestjs/core';
4import { RomanExceptionFilter } from './filters/roman-exception.filter';
5
6@Module({
7  providers: [{ provide: APP_FILTER, useClass: RomanExceptionFilter }],
8})
9export class AppModule {}

It works the same way as useGlobalFilters, but now the filter can inject, for example, a logger. That is my recommendation.

Catch-All Filter - the supreme tribunal

A filter with an empty @Catch() catches everything, including errors that are not an HttpException, such as a dropped database connection:

1// filters/all-exceptions.filter.ts
2import {
3  ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
4} from '@nestjs/common';
5
6@Catch() // Without argument = catches EVERYTHING
7export class AllExceptionsFilter implements ExceptionFilter {
8  catch(exception: unknown, host: ArgumentsHost) {
9    const ctx = host.switchToHttp();
10    const response = ctx.getResponse();
11    const request = ctx.getRequest();
12
13    const status = exception instanceof HttpException
14      ? exception.getStatus()
15      : HttpStatus.INTERNAL_SERVER_ERROR;
16
17    const message = exception instanceof HttpException
18      ? exception.message
19      : 'Internal empire server error!';
20
21    response.status(status).json({
22      statusCode: status,
23      error: message,
24      path: request.url,
25      timestamp: new Date().toISOString(),
26    });
27  }
28}

The instanceof operator splits two paths: HTTP exceptions keep their status and message, and everything else gets 500 and a generic text. Never send the client the exception.message of an unknown error - it could reveal database details. Write such an error to the logs instead. A single filter can also handle several types at once: @Catch(HttpException, TypeError).

Filter execution order

Filters are the only part of the request lifecycle that does not start at the global level:

  1. Method filter (if present) - checked first
  2. Controller filter (if present) - checked second
  3. Global filter (if present) - checked last

Only one filter handles an exception. If the method filter takes it, the controller filter and the global filter are not called. But when the method filter's @Catch() names a different exception type, the case goes up to the controller. At the same level, declare the catch-everything filter before the more specific one, so that the latter can still handle its own type.

In the next lesson you will see where in the request lifecycle filters do their work, and the whole of module 6 is devoted to error handling.

Remember: an exception is the accusation, and a filter is the tribunal that turns it into a clear verdict for the client.

Code for this lesson: src/exception-filters.ts
1// Exception Filters - Courts and Tribunals of the Empire
2import {
3  ExceptionFilter, Catch, ArgumentsHost,
4  HttpException, HttpStatus, NotFoundException,
5  BadRequestException, ForbiddenException,
6} from '@nestjs/common';
7
8console.log("Exception Filters - the empire's court system!");
9
10// ===========================================
11// 1. Built-in NestJS exceptions
12// ===========================================
13
14function demonstrateExceptions() {
15  // 404 Not Found
16  // throw new NotFoundException('Tributum non inventum!');
17
18  // 400 Bad Request
19  // throw new BadRequestException('Missing required data!');
20
21  // 403 Forbidden
22  // throw new ForbiddenException('Access denied!');
23
24  // Any HTTP code
25  // throw new HttpException('Message', HttpStatus.I_AM_A_TEAPOT);
26
27  console.log("Built-in exceptions: NotFoundException, BadRequestException...");
28  console.log("Each generates the appropriate HTTP code automatically");
29}
30
31demonstrateExceptions();
32
33// ===========================================
34// 2. Custom Exception Filter
35// ===========================================
36
37@Catch(HttpException)
38class RomanExceptionFilter implements ExceptionFilter {
39  catch(exception: HttpException, host: ArgumentsHost) {
40    const ctx = host.switchToHttp();
41    const response = ctx.getResponse();
42    const request = ctx.getRequest();
43    const status = exception.getStatus();
44
45    response.status(status).json({
46      statusCode: status,
47      imperium: 'Roma Aeterna',
48      error: exception.message,
49      path: request.url,
50      timestamp: new Date().toISOString(),
51    });
52  }
53}
54
55console.log("\n@Catch(HttpException) - catches HTTP exceptions");
56console.log("@Catch() - catches ALL exceptions");
57
58// ===========================================
59// 3. Bindowanie filtrow
60// ===========================================
61
62console.log("\nBinding levels:");
63console.log("@UseFilters(filter) on method - only that method");
64console.log("@UseFilters(filter) on controller - entire controller");
65console.log("app.useGlobalFilters(filter) - entire application");
66
67console.log("\n=== EXCEPTION FILTERS SUMMARY ===");
68console.log("HttpException - base HTTP exception");
69console.log("@Catch(Type) - exception filter decorator");
70console.log("ExceptionFilter - interface for implementation");
71console.log("@UseFilters() - binding to method/controller");
72console.log("Order: method > controller > global");
73

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 HTTP code does throwing new NotFoundException() generate in NestJS?

  2. 2. What does the @Catch(HttpException) decorator on an exception filter class mean?

  3. 3. How do you create an exception filter that catches ALL exception types (not just HttpException)?

  4. 4. If a filter is defined at both the method and controller level, which one is executed first?

Hands-on tasks in the game

  • Code editor

    Write a RomanExceptionFilter with @Catch(HttpException) that returns JSON with fields: statusCode, imperium, error, path, timestamp

  • Vertical ordering

    Order the Exception Filter binding levels from the narrowest to the widest scope:

  • Click in order

    Arrange the parts of an exception filter's catch() method in the correct order:

  • Code editor

    Write an AllExceptionsFilter with @Catch() (no arguments) that handles both HttpException and other errors

  • Horizontal ordering

    Arrange the elements of throwing a 404 exception in NestJS:

Useful articles