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!
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 -
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.
- Each server resource has a separate endpoint and hence a separate route.
- I try to keep controllers as lightweight as possible. Controllers are just meant to process the HTTP request and response.
- All the business logic is outsourced in service files. Interacting with DB and all the business operations are done in service files.
- Middlewares are placed before controllers to do checks like authentication, input validation and sanitation and such things.
- 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 ✨