Use Nest's built-in HTTP exceptions
Error handling in REST APIs is more than returning the right status code—it's about communicating intent and context to your client. NestJS provides a suite of built-in HTTP exceptions that let you throw meaning, not status codes, and handle domain errors consistently across your entire application. In this post, I'll show you how to leverage exceptions like NotFoundException, ConflictException, and ForbiddenException to build APIs that fail loudly and clearly.
Why built-in exceptions matter
Over five years building platforms—from e-commerce to ride-hailing systems—I've seen two patterns emerge. Junior teams throw raw Error objects or generic HttpException with magic status codes sprinkled throughout the codebase. Senior teams throw domain-specific exceptions that map to HTTP responses in one central filter. The second approach scales.
Built-in HTTP exceptions aren't just convenience—they're a contract between your service layer and your HTTP layer. When you throw NotFoundException, the framework handles serialization, status code, and response shape. Your domain code stays clean; your API stays consistent.
NotFoundException, ConflictException, ForbiddenException
NestJS ships with a rich set of HTTP exceptions that map directly to standard HTTP status codes. Let me walk through the most common ones with real-world scenarios.
NotFoundException (404)
Thrown when a resource doesn't exist. In an e-commerce platform, this is your best friend when a product, order, or user isn't found.
// product.service.ts
async getProduct(id: string) {
const product = await this.db.product.findUnique({ where: { id } });
if (!product) {
throw new NotFoundException(`Product ${id} not found`);
}
return product;
}
// product.controller.ts
@Get(':id')
async findOne(@Param('id') id: string) {
return this.productService.getProduct(id);
}
Client receives a 404 with a clear message. No ambiguity. No magic status codes hiding in your service layer.
ConflictException (409)
Thrown when a resource already exists or state violates a constraint. In a ride-hailing system, this fires when you try to book a ride that's already been assigned.
// ride.service.ts
async assignDriver(rideId: string, driverId: string) {
const ride = await this.db.ride.findUnique({ where: { id: rideId } });
if (ride.status !== 'PENDING') {
throw new ConflictException(
`Ride ${rideId} is already assigned or completed`
);
}
return this.db.ride.update({
where: { id: rideId },
data: { driverId, status: 'ASSIGNED' }
});
}
409 tells the client "your request is valid, but the current state won't allow it." Retry logic, UI flows, and mobile apps all benefit from this semantic clarity.
ForbiddenException (403)
Thrown when a user is authenticated but lacks permission. Different from 401 (unauthenticated). In a SaaS platform, this is when a user tries to access another organization's data.
// document.service.ts
async deleteDocument(docId: string, userId: string) {
const doc = await this.db.document.findUnique({
where: { id: docId },
include: { owner: true }
});
if (doc.owner.id !== userId) {
throw new ForbiddenException(
'You do not have permission to delete this document'
);
}
return this.db.document.delete({ where: { id: docId } });
}
The distinction between 403 and 404 is worth noting: some teams throw 404 to hide the existence of a resource from unauthorized users. That's valid; choose deliberately.
Pass a message or object for details
HTTP exceptions accept either a string message or an object with message and description fields. This lets you encode context without bloating the response.
Simple message
throw new NotFoundException('User not found');
Response:
{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}
Object with description
For validation errors, missing fields, or complex state, pass an object:
throw new BadRequestException({
message: 'Validation failed',
description: 'Email format is invalid. Example: user@example.com'
});
Response:
{
"statusCode": 400,
"message": "Validation failed",
"description": "Email format is invalid. Example: user@example.com",
"error": "Bad Request"
}
In production trading systems I've worked on, we've passed structured error codes alongside messages so clients can i18n or route errors programmatically:
throw new ConflictException({
message: 'Order cannot be modified',
description: 'Order is already filled',
code: 'ORDER_FILLED'
});
Then in your exception filter (more on this below), you can attach that code to the response for client-side handling.
Domain errors map to them in one filter
Here's where the real power emerges. Your domain layer throws business exceptions; your HTTP layer catches them and translates to HTTP exceptions. This keeps your service code independent of web concerns.
Define domain exceptions
// domain/exceptions.ts
export class ProductNotFoundError extends Error {
constructor(productId: string) {
super(`Product ${productId} does not exist`);
this.name = 'ProductNotFoundError';
}
}
export class ProductOutOfStockError extends Error {
constructor(productId: string, requested: number, available: number) {
super(
`Product ${productId}: requested ${requested}, available ${available}`
);
this.name = 'ProductOutOfStockError';
}
}
export class InsufficientPermissionError extends Error {
constructor(userId: string, resource: string) {
super(`User ${userId} cannot access ${resource}`);
this.name = 'InsufficientPermissionError';
}
}
Service throws domain exceptions
// product.service.ts
async purchaseProduct(productId: string, quantity: number) {
const product = await this.db.product.findUnique({
where: { id: productId }
});
if (!product) {
throw new ProductNotFoundError(productId);
}
if (product.stock < quantity) {
throw new ProductOutOfStockError(productId, quantity, product.stock);
}
return this.db.product.update({
where: { id: productId },
data: { stock: product.stock - quantity }
});
}
Exception filter translates to HTTP
// common/filters/domain-exception.filter.ts
import {
Catch,
ArgumentsHost,
ExceptionFilter,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ProductNotFoundError, ProductOutOfStockError, InsufficientPermissionError } from '@/domain/exceptions';
@Catch(
ProductNotFoundError,
ProductOutOfStockError,
InsufficientPermissionError
)
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof ProductNotFoundError) {
statusCode = HttpStatus.NOT_FOUND;
message = exception.message;
} else if (exception instanceof ProductOutOfStockError) {
statusCode = HttpStatus.CONFLICT;
message = exception.message;
} else if (exception instanceof InsufficientPermissionError) {
statusCode = HttpStatus.FORBIDDEN;
message = exception.message;
}
response.status(statusCode).json({
statusCode,
message,
timestamp: new Date().toISOString(),
});
}
}
// main.ts
app.useGlobalFilters(new DomainExceptionFilter());
Now your service layer is framework-agnostic. You can test it without NestJS, reuse it in a CLI, or swap HTTP for gRPC. Your HTTP layer is thin and consistent.
Never throw raw Error for client mistakes
This is the golden rule I've enforced in every codebase: never expose a raw Error or Error subclass to the HTTP layer unless explicitly caught and translated.
What not to do
// ❌ Bad: raw Error leaks to client
async updateUser(id: string, data: any) {
if (!data.email) {
throw new Error('Email is required');
}
return this.db.user.update({ where: { id }, data });
}
Client sees a 500 Internal Server Error with "Email is required." That's confusing and exposes your implementation details.
What to do instead
// ✅ Good: use HTTP exception
async updateUser(id: string, data: any) {
if (!data.email) {
throw new BadRequestException('Email is required');
}
return this.db.user.update({ where: { id }, data });
}
Client sees a 400 Bad Request with "Email is required." Clear, actionable, correct status code.
Common mistakes
Throwing the same exception for different reasons
// ❌ Ambiguous
async getOrder(orderId: string) {
const order = await this.db.order.findUnique({
where: { id: orderId },
include: { customer: true }
});
if (!order) {
throw new NotFoundException('Not found'); // Is it the order or the customer?
}
return order;
}
// ✅ Specific
async getOrder(orderId: string) {
const order = await this.db.order.findUnique({
where: { id: orderId }
});
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`);
}
return order;
}
Mixing HTTP exceptions with domain exceptions inconsistently
Pick one pattern: either throw HTTP exceptions everywhere (simpler, but couples domain to web), or throw domain exceptions and map them in a filter (cleaner, more testable). Don't mix both randomly.
Catching and re-throwing without context
// ❌ Loses information
try {
await this.dbService.update(id, data);
} catch (error) {
throw new BadRequestException('Operation failed');
}
// ✅ Preserve context
try {
await this.dbService.update(id, data);
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException('Email already in use');
}
throw error; // Let global filter handle unknown errors
}
Forgetting status codes for custom exceptions
If you extend HttpException directly for custom errors, always set the status code explicitly:
// ❌ Defaults to 500
class MyCustomError extends HttpException {
constructor(message: string) {
super(message, undefined);
}
}
// ✅ Explicit status
class MyCustomError extends HttpException {
constructor(message: string) {
super(message, HttpStatus.UNPROCESSABLE_ENTITY);
}
}
Key takeaways
- Use built-in HTTP exceptions (
NotFoundException,ConflictException,ForbiddenException, etc.) to communicate intent, not just status codes. - Pass descriptive messages or objects with
messageanddescriptionfields to help clients understand what went wrong. - Define domain-specific exceptions in your business logic; translate them to HTTP exceptions in a global exception filter.
- Never throw raw
Errorfor client mistakes; always use an appropriate HTTP exception with a 4xx status code. - Be consistent: choose either HTTP exceptions everywhere or domain exceptions + filter, but don't mix them randomly.
- Always include enough context in error messages for debugging and client-side handling.






Comments
Loading comments…