Error Handling Standards
Services throw custom errors, controllers convert them to HTTP exceptions. Keeps business logic independent of HTTP.
Error File Structure
Each module has module-name.error.ts defining error codes and error classes.
export const ERROR_CODES = {
NOT_FOUND: 'user.notFound',
DUPLICATE_EMAIL: 'user.duplicateEmail',
VALIDATION_FAILED: 'user.validationFailed',
} as const;
export class UserNotFoundError extends Error {
constructor(public userId: string) {
super(`User not found: ${userId}`);
this.name = ERROR_CODES.NOT_FOUND;
}
}
export class DuplicateEmailError extends Error {
constructor(public email: string) {
super(`Email already exists: ${email}`);
this.name = ERROR_CODES.DUPLICATE_EMAIL;
}
}
export class UserValidationError extends Error {
constructor(
public field: string,
public reason: string,
) {
super(`Validation failed for ${field}: ${reason}`);
this.name = ERROR_CODES.VALIDATION_FAILED;
}
}
Error Codes
Use dot notation: moduleName.errorType
export const ERROR_CODES = {
// Keys: SCREAMING_SNAKE_CASE
// Values: dot notation with camelCase
NOT_FOUND: 'integration.notFound',
ALREADY_EXISTS: 'integration.alreadyExists',
INVALID_INPUT: 'integration.invalidInput',
PARSE_ERROR: 'integration.parseError',
} as const;
Service Pattern
Services throw custom errors.
import { Injectable } from '@nestjs/common';
import { UserNotFoundError, DuplicateEmailError } from './user.error';
@Injectable()
export class UserService {
async readOne(values: UserReadOneInput): Promise<User> {
const entity = await this.repository.findOne({ where: { id: values.id } });
if (!entity) {
throw new UserNotFoundError(values.id); // Throw custom error
}
return this.toUser(entity);
}
async createOne(values: UserCreateInput): Promise<User> {
const existing = await this.repository.findOne({
where: { email: values.email },
});
if (existing) {
throw new DuplicateEmailError(values.email); // Throw custom error
}
const user = await this.repository.save(values);
return this.toUser(user);
}
}
Controller Pattern
Controllers catch custom errors and convert to HTTP exceptions.
import { Controller, Get, Post, Param, Body, NotFoundException, BadRequestException } from '@nestjs/common';
import { UserNotFoundError, DuplicateEmailError } from './user.error';
@Controller({ version: '1', path: 'users' })
export class UserController {
constructor(private readonly userService: UserService) {}
@Get(':id')
async readOne(@Param('id') id: string): Promise<UserResponseDTO> {
try {
const result = await this.userService.readOne({ id });
return new UserResponseDTO({ result });
} catch (error) {
// Convert custom error to HTTP exception
if (error instanceof UserNotFoundError) {
throw new NotFoundException(error.message);
}
throw error; // Unknown errors become 500
}
}
@Post()
async createOne(@Body() body: CreateUserBodyDTO): Promise<UserResponseDTO> {
try {
const result = await this.userService.createOne(body);
return new UserResponseDTO({ result });
} catch (error) {
if (error instanceof DuplicateEmailError) {
throw new BadRequestException(error.message);
}
throw error;
}
}
}
HTTP Exception Mapping
Common mappings:
| Custom Error | HTTP Exception | Status Code |
|---|---|---|
*NotFoundError | NotFoundException | 404 |
*ValidationError | BadRequestException | 400 |
*AlreadyExistsError | ConflictException | 409 |
*UnauthorizedError | UnauthorizedException | 401 |
*ForbiddenError | ForbiddenException | 403 |
Wrapping External Errors
When catching errors from external systems, wrap them in custom errors.
export class ApiCallError extends Error {
constructor(
public apiName: string,
public originalError: Error,
) {
super(`API call to ${apiName} failed: ${originalError.message}`);
this.name = ERROR_CODES.API_ERROR;
}
}
// Usage
try {
const response = await fetch(apiUrl);
return response.json();
} catch (error) {
throw new ApiCallError('ExternalAPI', error as Error);
}
Anti-Patterns
❌ Don't throw HTTP exceptions from services Services should be HTTP-agnostic.
// Bad - HTTP exception in service
async readOne(id: string) {
const user = await this.repository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found'); // WRONG!
}
return user;
}
// Good - custom error
async readOne(id: string) {
const user = await this.repository.findOne({ where: { id } });
if (!user) {
throw new UserNotFoundError(id); // RIGHT
}
return this.toUser(user);
}
❌ Don't use generic Error class Create specific error classes.
// Bad - generic error
throw new Error('User not found');
// Good - custom error class
throw new UserNotFoundError(userId);
❌ Don't forget to set error.name The name property is used for logging and monitoring.
// Bad - no name
export class UserNotFoundError extends Error {
constructor(public userId: string) {
super(`User not found: ${userId}`);
// Missing: this.name = ERROR_CODES.NOT_FOUND;
}
}
// Good - sets name
export class UserNotFoundError extends Error {
constructor(public userId: string) {
super(`User not found: ${userId}`);
this.name = ERROR_CODES.NOT_FOUND; // Required
}
}
❌ Don't skip error context Include relevant context in constructor parameters.
// Bad - no context
export class ValidationError extends Error {
constructor() {
super('Validation failed');
}
}
// Good - includes context
export class ValidationError extends Error {
constructor(public field: string, public reason: string) {
super(`Validation failed for ${field}: ${reason}`);
this.name = ERROR_CODES.VALIDATION_FAILED;
}
}
Other mistakes:
- ❌ Not catching errors in controllers
- ❌ Using string literals instead of ERROR_CODES constants
- ❌ Forgetting
as conston ERROR_CODES - ❌ Not making constructor parameters
public