Error Handling in NestJS: Best Practices for Writing Robust Code

Error handling in NestJS is crucial for robust code. Use custom exceptions, filters, pipes, and interceptors. Implement proper logging, handle async errors, and provide clear error messages. Test error scenarios thoroughly.

Error Handling in NestJS: Best Practices for Writing Robust Code

Error handling is a crucial aspect of writing robust code in NestJS. As a developer, I’ve learned the hard way that ignoring proper error handling can lead to some serious headaches down the line. Trust me, you don’t want to be scrambling to fix issues in production when your app crashes unexpectedly.

So, let’s dive into some best practices for error handling in NestJS that’ll help you write more reliable and maintainable code. I’ll share some personal experiences and examples along the way to make things more relatable.

First things first, let’s talk about the importance of using custom exceptions. NestJS provides a built-in HttpException class, but creating your own custom exceptions can make your code more expressive and easier to understand. I remember working on a project where we had generic error messages everywhere, and it was a nightmare to debug. That’s when I realized the power of custom exceptions.

Here’s an example of how you can create a custom exception in NestJS:

export class UserNotFoundException extends HttpException {
  constructor(userId: string) {
    super(`User with ID ${userId} not found`, HttpStatus.NOT_FOUND);
  }
}

Now, whenever you need to throw this exception, you can do something like this:

throw new UserNotFoundException(userId);

This makes your code much more readable and self-explanatory. Plus, it’s easier to catch specific exceptions and handle them accordingly.

Speaking of catching exceptions, let’s talk about exception filters. These bad boys are super useful for handling exceptions globally in your NestJS application. I remember the first time I discovered exception filters – it was like finding a secret weapon to tackle error handling.

Here’s a simple example of a global exception filter:

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

To use this filter globally, you can add it to your main.ts file:

app.useGlobalFilters(new AllExceptionsFilter());

Now, all unhandled exceptions in your application will be caught and formatted consistently. It’s like having a safety net for your entire app.

But what about validation errors? That’s where pipes come in handy. NestJS provides the ValidationPipe which can automatically validate incoming requests based on your DTO classes. It’s a game-changer when it comes to ensuring data integrity and preventing invalid data from reaching your business logic.

Here’s how you can set up the ValidationPipe globally:

app.useGlobalPipes(new ValidationPipe({
  transform: true,
  whitelist: true,
  forbidNonWhitelisted: true,
}));

With this setup, any request that doesn’t match your DTO specifications will be automatically rejected with a 400 Bad Request error. It’s like having a bouncer at the entrance of your API, keeping the riffraff out.

Now, let’s talk about async error handling. When working with asynchronous operations, it’s crucial to properly catch and handle errors. I’ve seen many developers (myself included, in the early days) forgetting to handle promise rejections, leading to unhandled promise rejection warnings and potential app crashes.

Here’s an example of how to properly handle async errors in a NestJS service:

@Injectable()
export class UserService {
  async findUser(id: string): Promise<User> {
    try {
      const user = await this.userRepository.findOne(id);
      if (!user) {
        throw new UserNotFoundException(id);
      }
      return user;
    } catch (error) {
      if (error instanceof UserNotFoundException) {
        throw error;
      }
      throw new InternalServerErrorException('An error occurred while fetching the user');
    }
  }
}

In this example, we’re catching specific exceptions and rethrowing them, while wrapping other unexpected errors in a generic InternalServerErrorException. This approach gives you fine-grained control over error handling and helps prevent sensitive error details from leaking to the client.

Another important aspect of error handling is logging. Proper logging can be a lifesaver when it comes to debugging issues in production. NestJS provides a built-in Logger class that you can use throughout your application. I always make sure to log important events and errors in my apps – it’s saved me countless hours of head-scratching when things go wrong.

Here’s an example of how you can use the Logger in a NestJS service:

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class UserService {
  private readonly logger = new Logger(UserService.name);

  async createUser(userData: CreateUserDto): Promise<User> {
    try {
      const user = await this.userRepository.create(userData);
      this.logger.log(`User created: ${user.id}`);
      return user;
    } catch (error) {
      this.logger.error(`Failed to create user: ${error.message}`, error.stack);
      throw new InternalServerErrorException('Failed to create user');
    }
  }
}

This approach ensures that you have detailed logs of what’s happening in your application, making it easier to trace issues and understand the flow of your app.

Now, let’s talk about handling errors in HTTP interceptors. Interceptors are a powerful feature in NestJS that allow you to add extra logic before or after the execution of the main handler. They’re great for things like logging, transforming responses, or handling errors.

Here’s an example of an interceptor that catches and handles errors:

@Injectable()
export class ErrorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(err => {
        if (err instanceof HttpException) {
          return throwError(() => err);
        }
        return throwError(() => new InternalServerErrorException('Something went wrong'));
      })
    );
  }
}

This interceptor catches any errors thrown during the request processing, allows HttpExceptions to pass through unchanged, and wraps any other errors in an InternalServerErrorException. It’s a great way to ensure consistent error responses across your application.

Let’s not forget about database error handling. If you’re using an ORM like TypeORM with NestJS, it’s important to properly handle database-related errors. These can be tricky because they often come with their own set of error types and codes.

Here’s an example of how you might handle a unique constraint violation in a NestJS service:

@Injectable()
export class UserService {
  async createUser(userData: CreateUserDto): Promise<User> {
    try {
      return await this.userRepository.save(userData);
    } catch (error) {
      if (error.code === '23505') { // PostgreSQL unique constraint violation code
        throw new ConflictException('User with this email already exists');
      }
      throw new InternalServerErrorException('Failed to create user');
    }
  }
}

In this example, we’re catching the specific error code for a unique constraint violation and throwing a more user-friendly ConflictException. This approach helps translate database-specific errors into HTTP-friendly responses.

When it comes to error handling in NestJS, it’s also worth mentioning the importance of proper error messages. Clear, concise error messages can greatly improve the developer experience for those consuming your API. I always try to provide meaningful error messages that explain what went wrong and, if possible, how to fix it.

For instance, instead of a generic “Validation failed” message, you could return something like “Invalid email format. Please provide a valid email address.” This kind of detailed feedback can save a lot of time and frustration for frontend developers or API consumers.

Lastly, don’t forget about error handling in your tests. Proper error handling isn’t just about preventing crashes in production – it’s also about making your code more testable. When writing unit tests or integration tests for your NestJS application, make sure to include test cases for error scenarios.

Here’s an example of how you might test error handling in a NestJS controller:

describe('UserController', () => {
  it('should throw UserNotFoundException when user is not found', async () => {
    const userId = 'non-existent-id';
    userService.findUser.mockRejectedValue(new UserNotFoundException(userId));

    await expect(controller.getUser(userId)).rejects.toThrow(UserNotFoundException);
  });
});

This test ensures that your controller properly propagates the UserNotFoundException when the user is not found. By including such tests, you can be more confident that your error handling code works as expected.

In conclusion, error handling is a critical aspect of building robust NestJS applications. By leveraging custom exceptions, exception filters, pipes, interceptors, and proper logging, you can create a more resilient and maintainable codebase. Remember, good error handling isn’t just about preventing crashes – it’s about providing a better experience for both developers and end-users. So, take the time to implement proper error handling in your NestJS projects. Your future self (and your users) will thank you!