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 8.6 — When (and When Not) to Use GraphQL in Real Systems
GraphQL is powerful—but expensive. This post helps you decide, based on real constraints, whether GraphQL is worth adopting or whether REST will serve you better.
Part 8.5 — Backend Architecture: Controllers vs “Resolvers” in NestJS
If you squint a little, a NestJS controller method already is a resolver. This post shows how to embrace that idea without introducing GraphQL complexity.
Part 8.4 — Client-Driven Data with REST: Avoiding Over-Fetching
Over-fetching is a client problem as much as a server problem. This post shows how React apps can drive data needs intentionally—without turning REST APIs into guesswork.
Comments