Modular and Secure Error Handling in Express.js

Sanjay Nellutla
Fission Labs
Published in
8 min readMay 30, 2023

For any programming language or framework, error handling is one of the fundamental things on which we should focus. Proper error handling provides assurance that applications won’t break or cause issues in production environments.

So let’s explore how to handle errors in Express.js to respond properly with error messages to our HTTP requests without exposing confidential server information, program call stack, and database-related information.

Let's consider the below root folder as our project folder structure:

routes
auth.route.js(handles ['/login', '/register'] routes)
user.route.js
index.js
controllers
auth.controller.js(provides handler functions for ['/login', '/register'])
user.controller.js
index.js
services
user.service.js
index.js
libraries
// 3rd party libraries abstraction
authToken.js
appBuiler.js
dbBuilder.js
hash.js
index.js
middlewares
// Third party middleware's
bodyParserHandler.js
corsHandler.js
securityHeadersHandler.js
internationalizationHandler.js
// Application level
tenancyHandler.js
successResponseHandler.js
successLoggingHandler.js
// Route level middleware's
authenticationHandler.js
requestValidationHandler.js
// Error handling middleware's
errorResponseHandler.js
errorLoggingHandler.js
index.js
......
wrappers
withTryCatch.js
withValidationSchema.js
index.js
........
utils
getErrorResponse.js
logger.js
index.js
.......
constants
statusCodes.js
index.js
errors
common.js
user.js
errorData.js
apiError.js
index.js
...
app.js
server.js

Step 1 — Maintain all known errors in a separate folder, it becomes convenient to track or modify them in the future without touching the business logic.

// errors/common.js
const statusCodes = require('../constants/statusCodes');

const types = {
INVALID_REQUEST_PAYLOAD: 'INVALID_REQUEST_PAYLOAD',
UNAUTHORIZED_ACCESS: 'UNAUTHORIZED_ACCESS',
ACCESS_DENIED: 'ACCESS_DENIED',
INTERNAL_SERVER_ERROR: 'SOMETHING_WENT_WRONG',
};

const errors = {
[types.INVALID_REQUEST_PAYLOAD]: {
message: 'Invalid Request payload',
statusCode: statusCodes.BAD_REQUEST,
},
[types.UNAUTHORIZED_ACCESS]: {
message: 'Un Authorized request',
statusCode: statusCodes.UN_AUTHORIZE_ACCESS,
},
[types.ACCESS_DENIED]: {
message: 'Access Denied',
statusCode: statusCodes.ACCESS_DENIED,
},
[types.INTERNAL_SERVER_ERROR]: {
message: 'Something went wrong',
statusCode: statusCodes.INTERNEL_SERVER_ERROR,
},
};

module.exports = {
types,
errors,
};
// erros/user.js

const statusCodes = require('../constants/statusCodes');

const types = {
USER_EXSISTS: 'USER_EXISTS',
USER_NOT_FOUND: 'USER_NOT_FOUND',
WRONG_CREDENTIALS: 'WRONG_CREDENTIALS',
};

const errors = {
[types.USER_EXSISTS]: {
message: 'User Already Exists',
statusCode: statusCodes.BAD_REQUEST,
},
[types.USER_NOT_FOUND]: {
message: 'User Not found',
statusCode: statusCodes.NOT_FOUND,
},
[types.WRONG_CREDENTIALS]: {
message: 'Wrong Credentials',
statusCode: statusCodes.BAD_REQUEST,
},
};

module.exports = {
types,
errors,
};
// errors/errorData.js

const user = require('./user');
const common = require('./common');

const types = {
...user.types,
...common.types,
};

const errors = {
...user.errors,
...common.errors,
};

const = getErrorDataByType(type) => {
const data = errors[type || types.INTERNAL_SERVER_ERROR];
return data;
};

module.exports = {
types,
errors,
getErrorDataByType,
};

Step 2 — We can create an ApiError class where we pass a unique error name exported from the errors folder into the constructor function. The ApiError class will automatically inject the status code and message.

// errors/apiError.js

const { types, errors, getDataByErrorName } = require('./errorData');

class ApiError extends Error {
constructor(errorType, options = {}) {
const meta = getErrorDataByType(errorType);
const message = options.message || meta.message;
super(message);
this.message = message;
this.name = errorType;
this.status = false;
this.statusCode = options.statusCode || meta.statusCode;
this.reason = options.reason || {};
}
}

module.exports = ApiError;

Now that we have defined our ApiError class and have the error types ready, we can start throwing ApiError with specific error types as shown in the example below.

// Simple way to throw ApiErrors.
throw new ApiError(errorsTypes.USER_NOT_FOUND);

/*
If we need to pass information from the handler level
we can override the defined information in
errorsTypes.WRONG_CREDENTIALS data.
*/
throw new ApiError(errorsTypes.WRONG_CREDENTIALS, {
reason: {},
message: '',
statusCode: '',
});

Step 3 — Before we start using middleware or understanding the request-response flow, it is important to identify all possible functionalities that are reusable. These functionalities can be extracted into separate helper files.

  • The error response object is designed to handle unknown errors by setting them with a 500 status code and a default message of “Something went wrong.” For known errors, the response includes appropriate information such as the message, status code, name, and reason (if required).
// utils/getErrorResponse.js


/* Pure function which can transform and create a meaningful error response
object using error and request
*/
const getErrorResponse = (error, req) => {
const defaultMessage = 'Something went wrong';
/*
Using error types/identifiers we can define error messages for different
languages
*/
const localeMessage = req.__(`errors.${errorTypes.INTERNAL_SERVER_ERROR}`);
let errorResponse = {
name: errorTypes.INTERNAL_SERVER_ERROR,
message: localeMessage || defaultMessage,
statusCode: statusCodes.INTERNAL_SERVER_ERROR,
};
const isApiError = error && error.statusCode;

if (isApiError) {
const name = error.name || errorTypes.INTERNAL_SERVER_ERROR;
const message = error.message || defaultMessage;
const localeMessage = req.__(`errors.${name}`);
const statusCode = error.statusCode || statusCodes.INTERNAL_SERVER_ERROR;
errorResponse = {
name,
message: localeMessage || message,
statusCode,
reason: error?.reason,
};
}
return errorResponse;
};

module.exports = getErrorResponse;
  • Logging errors should include the complete error stack, requestId, userId, clientId, error name, userAgent, and other relevant information. All of this data can be logged in a centralized location.
// utils/logger.js

const logger = {
// Logger implementations can be done in many ways or using popular libraries.
};

const logSuccessResponse = (payload, res) => {
/* Custom log success response util function which can
transform payload and response to meaningful log.
*/
};

const logErrorResponse = (error, req) => {
/* Custom log error response util function which can
transform payload and request to meaningful log.
*/
const logPayload = {
...(error || {}),
requestId: req.id,
userId: req.userId,
clientId: req.clientId,
userAgent: req.userAgent,
statusCode: error?.statusCode || statusCodes.INTERNEL_SERVER_ERROR,
// ...All additional details based on use case
};
logger.error(logPayload);
};

Step 4— We can create a helper higher-order function called withTryCatch so that we don't have to write try and catch blocks in all handlers.

// withTryCatch.js

module.exports = (onTry) => (async (req, res, next) => {
try {
await onTry(req, res, next);
} catch (error) {
/*
Lets learn about next function in the middleware's section.
*/
next(error);
}
});

We can call withTryCatch when defining routes or middlewares so that any error occurring in the lifecycle of the handler can be caught in a common try/catch syntax.

// auth.route.js

// Router file to define all our routes and pass respective handlers

const router = getRouter();

router.post(
urls.login,
withValidationSchema(
_.pick(userValidationSchema, ['email', 'password']),
requestValidationHandler,
),
/*
All errors thrown in the life cycle of authController.login
can be catched here
*/
withTryCatch(authController.login),
);

router.post(
urls.register,
withValidationSchema(
userValidationSchema,
requestValidationHandler,
),
withTryCatch(authController.register),
);

router.get(urls.userByToken, withTryCatch(authController.getUserByToken));

As we can see in the example below since the register handler is wrapped around withTryCatch, we don't have to write try/catch statements again.

// auth.controller.js 

const register = async (req, res, next) => {
let user = null;
user = await userService.getByEmail(req.body.email);
if (user && user.id) {
throw new ApiError(errorTypes.USER_EXISTS);
} else {
user = await userService.create(req.body);
const payload = {
statusCode: statusCodes.CREATED,
message: 'Successfully Registered',
data: user,
};
nextWithSuccess({
payload,
res,
next,
});
}
};

Note: The usage of withTryCatch is optional. There may be cases where you want to handle catch blocks uniquely for each handler. withTryCatch is a helper that can be used to avoid repetitive code.

Step 5— In Express.js, middleware functions are an integral part of the request-response cycle, so let us explore middlewares and how middlewares can be used to handle errors in a better way.

Request-Response cycle

There are different types of middleware in Express.js, each serving a specific purpose:

  • Router-level middleware: These are bound to an instance of express.Router() and are executed for specific routes. They can be used to perform actions like authentication, validation, or any logic specific to a group of routes.
// requestValidationHandler.js

const requestValidationHandler = withTryCatch((req, res, next) => {
const { error } = onValidate(getSchema(req.validationSchema), req.body);
if (error) {
const reason = error && error.details && error.details.length
? error.details.map((item) => item.message)
: {};
throw new ApiError(errors.INVALID_REQUEST_PAYLOAD, {
reason,
});
} else {
next();
}
});
// user.route.js

router.put(
urls.users,
authenticationHandler,
accessHandler,
requestValidationHandler,
withTryCatch(userController.update),
);

router.delete(
`${urls.users}/:id`,
authenticationHandler,
accessHandler,
requestValidationHandler,
withTryCatch(userController.remove)
);
  • Application-level middleware: These are bound to the app object and are executed for every incoming request to the application. They can be used for tasks like logging, parsing request bodies, and setting headers.
// tenancyHandler.js

/*
This middleware will resolve tenancy and pass,
nessecarry parameters to next handlers.
*/

const X_CLIENT_ORIGIN = 'X-CLIENT-ORIGIN';

const tenancyHandler = ((req, res, next) => {
try {
const origin = req.headers[X_CLIENT_ORIGIN];
const db = dbConnections.getConnection(origin);
const tenantConfig = getTenantConfig(orign);
req.db = db;
req.tenantConfig = tenantConfig;
next();
} catch (error) {
/*
By calling next(error) the first error handling middleware will be called,
from series of error middleware's
*/
next(error);
}
});

/*
withTryCatch can also be used instead of wring try/catch statements.
*/


// module.exports = withTryCatch(tenancyHandler);
module.exports = tenancyHandler;
  • Error-handling middleware: These middleware functions have four parameters (err, req, res, next) and are utilized to handle errors that occur during the request-response cycle. They can be employed to log errors, customize error messages, or execute any required error handling. Here’s an example:
// errorLoggingHandler.js

/*
This middleware will be executed when next function is called with
error object as argument.
*/

const util = require('../utils');

const errorLoggingHandler = (app) => app.use((err, req, res, next) => {
util.logErrorResponse(err, req);
next(err);
});

module.exports = errorLoggingHandler;
// errorResponseHandler.js

/*
This middleware will be executed when next function is called with
error object as argument.
*/

const errorResponseHandler = (app) => app.use((err, req, res, next) => {
const errorPayload = util.getErrorResponse(err, req);
res.status(errorPayload.statusCode);
res.send(errorPayload);
});

module.exports = errorResponseHandler;

Errors can be thrown from any type of request-response handler. Since we are using error handling middlewares, whenever an error is thrown, we just need to pass that error to the next function as an argument. This will trigger the first error handler from the series of error handling middlewares.

Step 6 — At this stage, we have defined our error objects, helper functions, wrappers, and middleware. Now let’s define our app.js file.

Note: Remember to add all your error-handling middleware after third-party and application-level middleware.

// app.js

const { build } = require('./libraries/appBuilder');
const routerHandler = require('./routes');
const {
tenancyHandler,
errorLoggingHandler,
errorResponseHandler,
successLoggingHandler,
successResponseHandler,
bodyParserHandler,
corsHandler,
securityHeadersHandler,
internationalizationHandler,
} = require('./middlewares');

module.exports = () => {
const app = build();
// Third-party middlewares
app.use(bodyParserHandler);
app.use(securityHeadersHandler;
app.use(corsHandler);

// Application-level middleware
app.use(internationalizationHandler);
app.use(tenancyHandler);
app.use(routerHandler);
app.use(successLoggingHandler);
app.use(successResponseHandler);

// Error-handling middleware
app.use(errorLoggingHandler);
app.use(errorResponseHandler);
return app;
};

Conclusion:
In conclusion, implementing a modular and secured approach to error handling in Express.js is crucial for building robust and reliable web applications. By following the best practices discussed in this article, developers can ensure that their applications gracefully handle errors, maintain security, and provide a positive user experience.

Additionally, employing secure error handling techniques, such as sanitizing error messages and logging sensitive information appropriately, helps protect against potential vulnerabilities.

--

--

Sanjay Nellutla
Fission Labs

Working on JavaScript and JavaScript related technologies from 7+ years, having good experience in web development, problem solving and designing solutions.