Exception FiltersBasics · NestJS

Shape every HttpException response with a filter bound to the whole application, and see what the exceptions layer does with an error it does not recognise.

What you will learn

Read the theory for Exception Filters

All Basics lessons

All NestJS courses

loading types…

What you'll learn

  • Describe what the exceptions layer answers for an HttpException and for a plain Error, and why the two differ
  • Write an exception filter with @Catch(), ArgumentsHost and the response object, scoped to one exception family
  • Bind a filter globally and know which errors a global filter sees that a controller-scoped one does not

Exception Filters

Nest has an exceptions layer. Anything thrown from a handler, a pipe, a guard or middleware that your code does not catch ends up there, and the layer turns it into a response. You have been using it since lesson 3: NotFoundException became a 404 with a JSON body without a line of response code. This lesson is about what the layer does by default, and how to take over when the default body is not the one your API should send.

HttpException and its family

The layer understands HttpException from @nestjs/common, which takes a response and a status:

throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
{ "statusCode": 403, "message": "Forbidden" }

Nest ships a subclass for each common status (BadRequestException, UnauthorizedException, NotFoundException, ForbiddenException, ConflictException, UnprocessableEntityException and more). Those add the status text as error:

{ "message": "Cat #99 not found", "error": "Not Found", "statusCode": 404 }

Every HttpException has two methods a filter needs: getStatus() returns the status code, and getResponse() returns the body Nest would have sent, a string or an object. exception.message is the message alone.

Anything that is not an HttpException, a plain Error for example, is treated as unrecognised. The layer logs it with its stack trace and answers 500 with { "statusCode": 500, "message": "Internal server error" }, and the client learns nothing more. That is deliberate: a plain Error is a bug, and its message may contain things a client should not see. Throw HttpException subclasses for everything the client should be told about, and let plain errors stay opaque.

A filter

An exception filter takes over the response for the exceptions it declares. It is a class with a catch() method, decorated with @Catch() listing the exception types it handles. Here is a filter that catches everything and normalises the body, distinguishing the two kinds:

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;

    response.status(status).json({
      statusCode: status,
      error: exception instanceof HttpException ? exception.message : 'Internal server error',
    });
  }
}

Three things to see in it. @Catch() with no argument catches everything; @Catch(HttpException) would catch only that family and leave plain errors to the default handling, and @Catch(A, B) lists several. ArgumentsHost wraps the arguments of the original handler in a way that works for HTTP, WebSockets and microservices alike; switchToHttp() gives getRequest() and getResponse(), and the request carries url, method and everything else Express puts on it. And the filter writes the response itself with response.status().json(), so it must send one: a filter that returns without answering leaves the client waiting, exactly like middleware without next().

Binding a filter

Three scopes, as with pipes:

  • one handler: @UseFilters(HttpExceptionFilter) on the method;
  • one controller: the same decorator on the class;
  • the whole application: app.useGlobalFilters(new HttpExceptionFilter()) in main.ts.

Where you can, pass the class rather than an instance, so Nest creates it and can inject dependencies into it. A global filter also catches what no controller handles: an unknown route's 404 goes through it, while a controller-scoped filter never sees it, because there was no controller. Global filters bound in main.ts cannot inject dependencies; for that, register the filter as a provider under the APP_FILTER token in a module.

Where it sits

The exceptions layer runs last, after everything else, and only when something threw. A filter sees the exception and the original request; it does not see the handler's return value, because there was none.

Your task

The API should answer every error with the same shape: statusCode, an ISO timestamp, the request's path, and the message.

  1. Complete HttpExceptionFilter so it answers with that shape, and declare that it catches HttpException and nothing else.
  2. Bind it to the whole application.
  3. CatsService.create() throws a plain Error when the name is missing, which the client sees as an opaque 500. Throw the exception that says the request was bad instead, with the message name is required, so the filter can shape it.

CatsController also has a GET /cats/crash route that throws a plain Error, kept on purpose. It must keep answering with Nest's default 500 body: your filter declares what it catches, and a plain error is not that. The tests check a missing cat, a body without a name, an unknown route, the crash and a successful request; the timestamp is not compared.

When it fails

  • A request never answers: the filter does not send a response. catch() must end with response.status(...).json(...).
  • Errors still have Nest's default body: the filter is not bound, or is bound to a controller while the error came from outside one.
  • /cats/crash answers with your shape: the filter catches everything. Give @Catch() an argument.
  • response.status is not a function: getResponse() was called on the host, not on host.switchToHttp().

Remember

  • Throw HttpException subclasses for what the client should know; plain errors are bugs and stay opaque 500s.
  • A filter is @Catch(...) plus catch(exception, host), and it must send the response itself.
  • host.switchToHttp() gives the request and the response.
  • A global filter sees every error, including the 404 for a route no controller owns.
Stuck? Show a hint

In catch(): host.switchToHttp() gives you getResponse() and getRequest(); the status is exception.getStatus(); the path is the request's url; the timestamp is new Date().toISOString(). Answer with response.status(status).json({...}). @Catch(HttpException) on the class, app.useGlobalFilters() in main.ts, and BadRequestException in the service.