Exception Filters
Turning thrown errors into HTTP responses — in one place, with full detail in the log and none of it leaking to the client.
Nest's default behaviour is already reasonable: throw an HttpException and it becomes the matching HTTP response.
throw new NotFoundException(`User ${id} not found`);{ "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#
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() // 503Throw them from the service, not the controller. The service knows why the operation failed; the controller just passes the result along.
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.
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.
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#
@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');
}
}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:
@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:
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#
@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#
// 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.
Get the NestJS agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.