Part 3.4 — Error Handling, Pipes, Filters & Global Interceptors
Even the best backend breaks — bad input, invalid IDs, database errors, or unexpected failures.
A production-ready backend isn’t one that never breaks; it's one that breaks cleanly, consistently, and observably.
In this post, you’ll learn how to structure backend behavior using:
Pipes (validation & transformation)
Exception filters (error shape)
Interceptors (logging, response envelopes, timing)
Built-in error types
Custom HTTP exceptions
By the end, your NestJS backend will behave predictably under every condition.
1. Pipes: Where Input Becomes Safe & Typed
You already activated global validation in Part 3.2:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);This pipe enforces:
Only expected fields are accepted
DTO validation rules must pass
Primitive types auto-transform (e.g.,
"1"→1)
You can also write custom pipes.
Example: Parse non-numeric IDs:
import { PipeTransform, BadRequestException } from '@nestjs/common';
export class ParseIdPipe implements PipeTransform {
transform(value: string) {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new BadRequestException('ID must be a number');
}
return parsed;
}
}Use:
@Get(':id')
findOne(@Param('id', ParseIdPipe) id: number) { ... }This gives you precise control.
2. Exception Filters: Unified Error Shape
Without a filter, different errors produce different structures:
Prisma errors
Validation errors
NotFoundException
BadRequestException
Unexpected errors
Your frontend receives inconsistent shapes.
Create a global exception filter:
// src/common/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse();
let status = 500;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const resBody = exception.getResponse() as
| string
| { message: string | string[] };
message = typeof resBody === 'string' ? resBody : resBody.message;
}
res.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}Apply it globally:
app.useGlobalFilters(new GlobalExceptionFilter());Now your errors always look like:
{
"statusCode": 400,
"message": ["name must not be empty"],
"timestamp": "2025-11-18T10:34:00Z"
}This consistency helps your React frontend handle errors easily.
3. Handling Prisma Errors Safely
Prisma throws its own error classes.
You can catch them inside your filter:
import { Prisma } from '@prisma/client';
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') {
status = 409;
message = 'Unique constraint failed';
}
}Examples:
P2002 → Unique violation
P2025 → Record not found
P2003 → Foreign key constraint failed
Mapping backend errors to clear messages improves UX dramatically.
4. Interceptors: Wrapping & Transforming Responses
Interceptors let you:
Add response envelopes
Log incoming/outgoing data
Time requests
Modify responses
Cache results
Example: Logging + Timing Interceptor
// src/common/interceptors/logging.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const now = Date.now();
const req = ctx.switchToHttp().getRequest();
const { method, url } = req;
return next.handle().pipe(
tap(() => {
console.log(
`${method} ${url} - ${Date.now() - now}ms`,
);
}),
);
}
}Apply globally:
app.useGlobalInterceptors(new LoggingInterceptor());This gives clear logs like:
GET /users - 12ms
POST /posts - 33ms5. Response Transformation Interceptor (Optional)
Some teams prefer enveloped responses:
{
"data": { ... },
"success": true
}Example:
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(_: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map(data => ({
success: true,
data,
})),
);
}
}Apply globally to unify your frontend’s response expectations.
6. Custom Exceptions for Business Logic
NestJS gives you:
throw new NotFoundException('User not found');
throw new BadRequestException('Invalid input');
throw new ForbiddenException('You cannot edit this post');Use custom exceptions for clarity:
import { HttpException, HttpStatus } from '@nestjs/common';
export class AuthorMismatchException extends HttpException {
constructor() {
super('Author mismatch', HttpStatus.FORBIDDEN);
}
}Use in service:
if (post.authorId !== userId) {
throw new AuthorMismatchException();
}Readable. Self-explanatory. Maintainable.
7. Global Validation Exception Customization
Want uniform validation errors?
You can override the default NestJS validation exception:
export class CustomValidationException extends BadRequestException {
constructor(errors: any) {
super({
message: errors.map((e: any) => Object.values(e.constraints)).flat(),
});
}
}Then:
app.useGlobalPipes(
new ValidationPipe({
exceptionFactory: (errors) => new CustomValidationException(errors),
}),
);Your frontend now receives validation errors in a clean, predictable format.
8. Ensuring Transformations Are Safe
Always validate transformed input:
ParseIntPipe
ParseBoolPipe
ParseArrayPipe
Example:
@Query('limit', new ParseIntPipe({ errorHttpStatusCode: 400 }))
limit: numberDo not trust raw query strings.
9. Combine Everything Into a Production Pipeline
Your NestJS stack now handles:
DTO validation
Whitelist + transform pipes
Custom ID parsing
Global exception filter
Prisma error mapping
Logging interceptor
Response wrappers
A clean backend feels stable even when things go wrong.
10. Summary
You’ve built:
Robust DTO validation
Predictable global error handling
Structured responses
Prisma-safe error mapping
Logging & request timing
Transformation and safety via pipes
A backend your frontend can trust
In Part 3.5, you’ll connect these REST endpoints to your React clients and create shared API contracts across the full stack.
Related
Part 4.6 — Building a Maintainable Testing Architecture
Writing tests is easy. Maintaining hundreds of them is hard. This post shows how to structure your NestJS backend tests so they scale without pain.
Part 4.4 — Testing Auth Guards, JWT Strategy & Protected Routes
Authentication logic is critical—and fragile if untested. In this post, you’ll learn how to test JWT auth guards and protected REST routes with confidence.
Part 4.3 — Integration Testing with Supertest: REST Endpoints
Integration tests verify the entire request pipeline—controllers, DTOs, pipes, filters, interceptors, and database behavior. This post shows how to test your REST API like a professional.
Comments