# Exception Filters

> Source: https://learn-nestjs.com/exception-filters/
> Part of Learn NestJS, free to read.

Nest's default behaviour is already reasonable: throw an `HttpException` and it becomes the matching HTTP response.

```typescript
throw new NotFoundException(`User ${id} not found`);
```

```json
{ "statusCode": 404, "message": "User 1 not found", "error": "Not Found" }
```

Anything that is *not* an `HttpException` becomes a generic 500, with the detail logged rather than sent — which is the correct default.

## The built-in exceptions

```typescript
new BadRequestException()            // 400
new UnauthorizedException()          // 401 — not authenticated
new ForbiddenException()             // 403 — authenticated, not allowed
new NotFoundException()              // 404
new ConflictException()              // 409 — duplicate, version conflict
new UnprocessableEntityException()   // 422 — valid shape, invalid meaning
new TooManyRequestsException()       // 429
new InternalServerErrorException()   // 500
new ServiceUnavailableException()    // 503
```

Throw them from the **service**, not the controller. The service knows why the operation failed; the controller just passes the result along.

```typescript
async findOne(id: string, requester: AuthUser): Promise<Invoice> {
  const invoice = await this.repo.findById(id);
  if (!invoice || invoice.ownerId !== requester.id) {
    throw new NotFoundException();     // 404, not 403 — do not confirm it exists
  }
  return invoice;
}
```

That 404-not-403 choice matters: a 403 tells an attacker the id is real. See [the security review](/review/security/).

## Domain exceptions

Better still, throw exceptions that belong to your domain and map them to HTTP at the edge. Your service then has no idea it is being used over HTTP, which makes it reusable from a queue consumer or a CLI.

```typescript src/core/errors.ts
export class DomainError extends Error {}

export class NotFound extends DomainError {
  constructor(readonly resource: string, readonly id: string) {
    super(`${resource} ${id} not found`);
  }
}

export class InsufficientStock extends DomainError {
  constructor(readonly sku: string, readonly available: number) {
    super(`insufficient stock for ${sku}`);
  }
}
```

## A global filter

```typescript src/common/all-exceptions.filter.ts
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();

    const { status, message } = this.map(exception);

    // full detail to the log
    this.logger.error({
      err: exception,
      path: req.url,
      method: req.method,
      requestId: (req as any).id,
    }, 'request failed');

    // minimal detail to the client
    res.status(status).json({
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
      path: req.url,
    });
  }

  private map(e: unknown): { status: number; message: string } {
    if (e instanceof HttpException) {
      const r = e.getResponse();
      return {
        status: e.getStatus(),
        message: typeof r === 'string' ? r : ((r as any).message ?? e.message),
      };
    }
    if (e instanceof NotFound) return { status: 404, message: e.message };
    if (e instanceof InsufficientStock) return { status: 409, message: e.message };

    // Prisma / TypeORM errors must NOT reach the client — they name your tables
    if (this.isDatabaseError(e)) {
      return { status: 500, message: 'Internal server error' };
    }
    return { status: 500, message: 'Internal server error' };
  }

  private isDatabaseError(e: unknown): boolean {
    return typeof e === 'object' && e !== null && 'code' in e &&
      typeof (e as any).code === 'string' && (e as any).code.startsWith('P');
  }
}
```

```typescript
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }]
```

The shape to copy is the two-audience split: **everything to the log, almost nothing to the client.** A database error escaping to a response leaks your schema; a stack trace leaks your file layout.

## Scoped filters

`@Catch()` with no argument catches everything. With arguments it catches only those types:

```typescript
@Catch(NotFound, InsufficientStock)
export class DomainErrorFilter implements ExceptionFilter { /* … */ }
```

Filters are matched most-specific-first, so a scoped filter and a catch-all can coexist — the scoped one handles what it declares, the catch-all takes the rest.

## Validation errors

`ValidationPipe` throws a `BadRequestException` whose payload is an array of messages. To reshape it for a client that expects field-level errors:

```typescript
new ValidationPipe({
  whitelist: true,
  transform: true,
  exceptionFactory: (errors) =>
    new BadRequestException({
      statusCode: 400,
      message: 'Validation failed',
      fields: Object.fromEntries(
        errors.map((e) => [e.property, Object.values(e.constraints ?? {})]),
      ),
    }),
})
```

## What not to do

```typescript
@Get(':id')
async findOne(@Param('id') id: string) {
  try {
    return await this.service.findOne(id);
  } catch (err) {
    throw new InternalServerErrorException();    // destroys the real error
  }
}
```

Three problems: the original error and its stack are gone, a genuine 404 has become a 500, and the same `try/catch` now has to be repeated in every handler. Let the exception propagate and let the filter map it — that is what the filter is for.

## Exercise

```typescript
// 1. Define domain errors: `PaymentDeclined` (has a `reason`) and
//    `DuplicateOrder` (has an `existingId`).
// 2. Write a @Catch filter mapping PaymentDeclined -> 402 and
//    DuplicateOrder -> 409 with the existing id in the body.
// 3. Ensure anything unrecognised becomes a 500 with no detail leaked,
//    while the full error is logged.
```

## Common questions

### Should services throw HTTP exceptions?

It is common and pragmatic in a Nest-only codebase. Domain exceptions mapped in a filter are cleaner, because the service then works unchanged from a queue consumer or a CLI where HTTP status codes are meaningless. Pick one convention and state it in your instructions file.

### Where do I log the error — the filter or the service?

The filter, once. Logging in the service and re-throwing means one failure appears several times in your logs at different levels of detail. Add context by wrapping the error, and log at the boundary.

### Why is my filter not catching anything?

Usually ordering or scope: a more specific filter is handling it first, or a `try/catch` in the handler swallowed the exception before it reached the filter. Also check the filter is registered with `APP_FILTER` and not just declared.
