Understanding Node.js Error Handling: A Guide to Controller and Application-Level Strategies

Understanding Node.js Error Handling: A Guide to Controller and Application-Level Strategies

Introduction

Error handling is among the most essential skills a Node.js developer must possess without a doubt. Even though Error Handling is not peculiar to Node.js but it requires a special focus due to the natural pain points presented by the JavaScript language insert Weak Typing, Asynchronous Programming Model, Global Scope(historical practices still exists in some codebases), Single-Threaded Nature, Null and Undefined etc. You won’t look too hard before you find developers who are struggling to handle errors while a lot are completely missing it.

Having a robust error handling strategy cannot only sustain the velocity and trajectory of your release roadmap but it also reduces the development time and ensures a robust codebase. Is there need to mention that effective error handling is crucial for maintaining application stability and user experience. To make us understand errors properly, Node.js has two distinct categories of error:

  • Operational errors: these have to do with edge cases which occur while your application is active or in production. Example of such could be users entering unexpected data in form fields, third party application breakdown.

  • Programming errors: these are generally errors that occur in development while writing your code: syntax error, type error, logic error etc. It is good to mention that many syntax and type errors can be caught using TypeScript, Linting and smart code editors with autocompletion capabilities like VS Code.

Error Handling Strategies

Now to the main reason why we are here: depending on the point of influence within our application, there are two known strategies for handling errors in Node.js application vis-à-vis: controller-level error handling application-level error handling

Controller-Level Error Handling

Definition and purpose: this is focused on handling errors specific to particular request or route. It allows you to manage errors at a more granular level, providing tailored responses based on the context of the request.

Implementation Example:

You would typically create custom error classes to represent specific types of errors within your application. These classes might extend the built-in Error class or another appropriate base error class.

Here's an example of how you could define and use a custom error class

utils/errorhandling.ts

class AppError extends Error {
  public readonly name: string;
  public readonly statusCode: HttpStatusCode;
  public readonly isOperational: boolean;
  constructor(
    name: string,
    statusCode: HttpStatusCode,
    description: string,
    isOperational = true
  ) {
    super(description);
    Object.setPrototypeOf(this, new.target.prototype);

    this.name = name;
    this.statusCode = statusCode || HttpStatusCode.INTERNAL_SERVER_ERROR;
    this.isOperational = isOperational;

    Error.captureStackTrace(this);
  }
}

export class FileNotFoundError extends AppError {
  constructor(
    name = "FILE_NOT_FOUND",
    statusCode = HttpStatusCode.INTERNAL_SERVER_ERROR,
    description = "The file cannot be found in the specified location."
  ) {
    super(name, statusCode, description);
  }
}

Here is how you would use the custom error at controller-level

users/usersController.ts

class UsersController {
    async allUsers(_req: Request, res:Response ){
       try {
        const users = await usersService.allUsers()
        res.send({users})
       } catch (error) {
        throw new FileNotFoundError( )
       }

    }
}

In the code snippets above,

  • we created custom error classes by extending the built-in Node.js Error class.

  • We specified the error message that suits the job of the controller or route where it was going to be thrown (read used).

  • Finally we throw the error handler in the controller where it was designed for specifically.

And that is how you would mostly go about implementing Node.js error handling at the controller level.

Benefits

Controller-level error handling in a Node.js application provides several advantages, particularly in the context of handling errors specific to individual routes or controllers, a few of these advantages are mentioned below:

  • Context-Specific Handling: Controller-level error handling allows you to tailor error responses based on the specific context of a particular route or controller. This can result in more informative and user-friendly error messages.

  • Fine-Grained Control: You have fine-grained control over how errors are handled for different parts of your application. This is especially useful when you want to apply unique error-handling strategies for specific operations or features.

  • Isolation of Concerns: Errors are handled in close proximity to where they occur, promoting a clean separation of concerns. Each controller is responsible for managing errors related to its specific functionality, contributing to a more modular and maintainable codebase.

  • Consistent API Responses: By handling errors at the controller level, you can enforce a consistent API response format. This consistency makes it easier for consumers of your API to understand and handle errors on their end.

In summary, controller-level error handling offers advantages in terms of context-specific control, fine-grained customization, and isolation of concerns. It is particularly beneficial when you need to apply different error-handling strategies for various parts of your application.

Application-Level Error Handling

Definition and purpose: this is a broader approach that captures unhandled errors, often using middleware like Express's error-handling middleware. This ensures that if an error isn't caught at the controller level, it won't crash the entire application but can be gracefully handled.

Implementation Example:

/app.ts

import express, { Request, Response, NextFunction } from 'express';

const app = express();

class MyCustomError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'MyCustomError';
  }
}

// Middleware to simulate an error
app.get('/simulate-error', (req, res, next) => {
  next(new MyCustomError('custom error occurred!'));
});

// Application-level error handler middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);

  // Handle specific errors with custom responses
  if (err instanceof MyCustomError) {
    return res.status(400).json({ error: 'Bad Request' });
  }

  // Generic error response for other unhandled errors
  res.status(500).json({ error: 'Internal Server Error' });
});

const port = 3000;
app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

In this example, when you visit the "/simulate-error" route, it triggers an error, and the application-level error handler middleware captures and processes it.

So how does the error middleware know if an error is an instance of MyCustomError, you might ask?

In JavaScript and TypeScript, the instanceof operator is used to check if an object is an instance of a particular class or constructor function.

The key line is if (err instanceof MyCustomError). Here, it checks if the caught error (err) is an instance of the MyCustomError class. This is a language feature of JavaScript and TypeScript. When you throw an error using throw new MyCustomError('Custom error occurred!'), it creates an instance of MyCustomError. The instanceof operator then allows you to differentiate between different types of errors based on their class.

Benefits

Having a centralized error-handling mechanism in your Node.js application provides several advantages among which are:

  • Consistency: Centralized error handling ensures a consistent approach to managing errors across your application. It helps maintain a standardized response format, making it easier to understand and debug issues.

  • Code Organization: All error-handling logic is concentrated in one place, making your codebase cleaner and more organized. This separation of concerns improves maintainability and readability.

  • Drying Up Code: Don't Repeat Yourself (DRY) is a fundamental software development principle. Centralized error handling allows you to avoid duplicating error-handling logic in multiple places, reducing redundancy and potential inconsistencies.

  • Scalability: As your application grows, having a centralized error-handling strategy makes it easier to scale. It provides a foundation for managing errors consistently as you add more routes, controllers, or modules.

In summary, a centralized error-handling mechanism promotes consistency, code organization, and better maintenance of your Node.js application. It contributes to a more robust and reliable system by handling errors in a standardized way across different parts of your codebase.

Combining Strategies for Robust Error Handling

Now, the big question is, which strategy to use?

Both controller-level and application-level error handling play important roles in Node.js applications. Controller-level error handling is focused on handling errors specific to a particular request or route. It allows you to manage errors at a more granular level, providing tailored responses based on the context of the request.

On the other hand, application-level error handling is a broader approach that captures unhandled errors, often using middleware like Express's error-handling middleware. This ensures that if an error isn't caught at the controller level, it won't crash the entire application but can be gracefully handled.

In a well-designed application, combining both approaches is often the best practice. Controller-level handling deals with specific use case scenarios, while application-level handling acts as a safety net for unforeseen errors, preventing them from crashing the entire application and providing a consistent error-handling mechanism.

Conclusion

In this article, we discussed error handling in Node.js and its importance. We also identifies the two strategies employed in error handling which are: controller-level error handling and application-level error handling. In our discussion, we highlighted the strengths of each strategy and where they are preferable. We concluded that it is a good practice to combine both strategies in order to have a well-rounded error-handling strategy.

In conclusion, a well-rounded error-handling strategy in Node.js combines the strengths of both controller-level and application-level approaches. By leveraging custom error classes and adopting best practices, developers can create robust, scalable, and maintainable applications with a positive user experience.

It is encouraged that as developers, we tailor our approach based on the specific needs of our application. Kindly explore and implement these error handling strategies in your Node.js projects and let me hear about your experience.

You can visit this repository for general Node.js best practices

Also let me have your comments on my twitter handle @kennie_larkson