Skip to main content

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 ErrorHTTP ExceptionStatus Code
*NotFoundErrorNotFoundException404
*ValidationErrorBadRequestException400
*AlreadyExistsErrorConflictException409
*UnauthorizedErrorUnauthorizedException401
*ForbiddenErrorForbiddenException403

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 const on ERROR_CODES
  • ❌ Not making constructor parameters public