A smart way of handling errors in NodeJS

Author pic

Written By - Garvit Maloo

6 April, 2024

Error handling is a crucial aspect of any software development process, and NodeJS applications are no exception. Proper error handling not only enhances the reliability of your application but also contributes to better user experience and easier debugging. In this blog post, I am sharing how I am handling errors in my backend projects and how you can manage errors effectively. You'll also find appropriate code snippets in this post to get better understanding. Lets do it!

We'll be using TypeScript in the code snippets. I always prefer using TypeScript in my projects. It's always a good idea to add type checks to reduce errors and hence, LESS ERROR HANDLING 😉

A centralized error handling middleware

NodeJS provides us a special middleware that can be used to handle errors efficiently. FYI middlewares are normal functions which are placed in between routers and controllers to process the request from routers to controllers and make modifications or do checks in the request object, if required.

Usually, middlewares in NodeJS accepts three arguments - request, response and next function. But the middleware we use for error handling accepts four arguments - error, request, response and next function. Lets setup this middleware. Make a new folder called middleware in your root project and create a file called errors.ts and copy paste this code in the file. I assume you have a NodeJS-express TypeScript project setup. If not, you can simply clone this repository and get started.

import type { Request, Response, NextFunction } from "express";

export const handleErrors = (
  error: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  const statusCode = res.statusCode !== 200 ? res.statusCode : 500;
  res.status(statusCode).json({
    result: null,
    error: {
      statusCode,
      message: error.message
    }
  });
  next();
};

Next, we'll register this middleware in our app.ts file, the entry point of our backend application.

import express from "express";
import type { Request, Response, NextFunction } from "express";

import { handleErrors } from "./middleware/errors";

const app = express();

// ALL THE APP ROUTES HERE...

app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
  handleErrors(error, req, res, next);
})

// APP START CODE

Here's a screenshot of app.ts file from one of my projects -

Screenshot of app.ts file

Please note that it is extremely important to put the error handling middleware after all the app routes. This acts as a fallback for all the errors that might occur in any of the app routes and it will catch all the errors that we will process in these routes.

Now it's time to make use of this middleware to handle all errors at one place. But before that, a brief note on the architecture that I follow in my projects.

  1. Each server resource has a separate endpoint and hence a separate route.
  2. I try to keep controllers as lightweight as possible. Controllers are just meant to process the HTTP request and response.
  3. All the business logic is outsourced in service files. Interacting with DB and all the business operations are done in service files.
  4. Middlewares are placed before controllers to do checks like authentication, input validation and sanitation and such things.
  5. I follow a standard response approach from all the APIs. Response from all the APIs, whether error or actual result are of this type -
export interface IStandardResponse<T> {
    error: {
        statusCode: string,
        message: string
    } | null;
    result: T | null
}

Result from APIs can differ, hence a generic type is set. If there's some error, status code and message are set and result is set to null. If not, error is null and result type will be the type of result from the API.

Let's see this in action for a resource API.

The routes file for resource API looks something like this -

import { Router } from "express";

import {
  handleGetAllResources
} from "../controllers/resources";
import { isAuthenticated } from "../middleware/authentication";

const resourcesRouter = Router();

resourcesRouter.get("/", isAuthenticated, handleGetAllResources);

export { resourcesRouter };

Let's assume that the user is not authenticated and makes a request on this endpoint. The isAuthenticated middleware should return an error without allowing the request to make it to the controller. Lets see how this will happen. Here's the code for isAuthenticated middleware -

export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
    const token: string = req.cookies.token;

    if(!token){
        res.statusCode = 403; // forbidden
        next(new Error("You are not authenticated."))
        return;
    }

    try{
        // token verification successful
        next();
    }catch(err){
        // token verification fails
        res.statusCode = 400 // bad request
        next(new Error("Invalid token));
    }
}

If you pass an error object in the next() function, all the middlewares in the queue will be skipped and the error handling middleware will be called directly. If you call the next() function without any argument, the next middleware in the queue will be called. It's that simple. If you get any error in the middlewares, just call the next function with an appropriate error object. Also, we are setting statusCode on the res object because we might want to deliver an appropriate status code for different errors. So, if you see the code for error handling middleware, you will notice that first we check if we are getting any status code from previous middleware. If yes, we are setting that status code, other wise the default one, that is 500.

Let's assume that all the checks in the middlewares are passed and controller function is called, which in-turn calls the service function. Lets see how we can handle any errors in the service files. Here's the code for the service file -

export const fetchAllUserResources = async (userEmail: string): Promise<IStandardResponse<IResource>> => {
    try{
        const resources = await Resource.find({email: userEmail});

        return {
            error: null,
            result: resources
        }
    }catch(err){
        return {
            error: {
                statusCode: 500,
                message: (err as Error).message
            },
            result: null
        }
    }
}

Note the return type of the function - Promise<IStandardResponse<IResource>>. The result from the API (resources variable) will be of the type IResource, hence this return type is set.

The only job in this service file is to perform the business operation, that is fetch all resources from DB and return appropriate response. Now, we'll have to call this service in the controller to finally send an appropriate response and end the request-response cycle. Let's do it in the controller now.

export const handleGetAllResources = async (req, res, next): Promise<void> => {
    const userEmail = req.body.email;

    const response = await fetchAllUserResource(userEmail);

    // handle error from service
    if(response.error !== null){
        res.statusCode = response.error.statusCode
        next(new Error(response.error.message))
        return;
    }

    // No errors => Deliver final response
    res.status(200).json(response);
}

We simply check if there is any error in the service response. If error object is not null, call the next() function with the error message coming from service response. If error is null, we can send the final response and terminate the Request-Response cycle.

Hence, we can handle errors occurring in any of the layers - middleware, controller or service, all at one place, the mighty error handling middleware.

Further steps

An extension of error handling can be logging and monitoring. Its a good idea to keep a log of all the errors that occur in your system. It helps in debugging and identifying areas of improvements. I have integrated winston logger in the repository linked above. You can generate a separate file for all your error logs and reference it if needed while debugging.

That's it friends for this post. Would be back soon with a new one! If you liked the contents of this post, please share it with your connections. I'll really appreciate that! Happy learning ✨

Liked the content? Share it with your friends!
Share on LinkedIn
Share on WhatsApp
Share on Telegram

Related Posts

See All