Build a Global Exception Handler using Express.js & TypeScript

Mirza Leka
20 min readNov 23, 2023

A comprehensive guide to implementing HTTP Exception handling in Node.js, with logging, validations, separation of concerns, and various scenarios to play with.

Common concerns when handling errors in Express.js apps:

  1. How to use Exceptions with HTTP status codes?
  2. How to move functionalities out of controllers and still be able to send a proper error response?
  3. How to avoid duplicating Try-Catch blocks?
  4. How to use middlewares to handle errors?
  5. How to log exceptions?

If all this sounds overwhelming to you, I strongly advise you to read my article on the importance of Exception Handling.

This article is a continuation of my Express.js starter series — Express APIivantage. If you’re completely new or want to peek into existing features, this is a list of items developed so far:

  • Set up the started project with Express, TypeScript, SQL (TypeORM) & MongoDB (Mongoose) and created two controllers: users (Mongoose), and products (TypeORM)
  • Added API validations with Joi (users) & Class-Validator (products)
  • Created Automated Logging triggered on every response using Winston & custom interceptor middleware

Express.js Starter (Express-APIvantage)

4 stories

Now it’s time we do proper Exception Handling.

Creating HTTP Exceptions

JavaScript/Node.js does not have a built-in exception class for HTTP exceptions. To create them, we’ll make use of the HttpErrors NPM package.

$ npm install http-errors
$ npm install @types/http-errors --save-dev (for TypeScript)

Just for demonstration purposes, this is how you use the package:

import createError from 'http-errors';

function doSomething() {
throw createError(<status-code>, <optional-message>);
// throw exception with <status-code> and <message>
}

The createError() function takes two parameters, HTTP status code, and an optional exception message. Once you put the throw in front of the createError() function, it will produce an HTTP error with the status code you specified:

throw createError(400); // Bad Request Exception

🟠 HTTP Status Codes

Since Express.js does not have any kind of type safety for HTTP Status codes, I added these myself:

export enum HTTPStatusCode {
Ok = 200,
Created = 201,
Accepted = 202,
NoContent = 204,
PartialContent = 206,
MultipleChoices = 300,
MovedPermanently = 301,
Found = 302,
BadRequest = 400,
Unauthorized = 401,
PaymentRequired = 402,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
RequestTimeout = 408,
Conflict = 409,
Gone = 410,
UnprocessableEntity = 422,
TooManyRequests = 429,
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
GatewayTiemout = 504
}

🟠 Exception Classes

We’ll abstract the createError() function from the HttpErrors package with exception classes that are throwable anywhere. Each HTTP status code will have a unique class, e.g. BadRequestException, NotFoundException, etc.

export class BadRequestException {
constructor(message = 'Bad Request') {
throw createError(400, message);
}
}

export class BadRequestException {
constructor(message = 'Internal Server Error') {
throw createError(HTTPStatusCode.InternalServerError, message);
}
}

// the same goes for other exception classes

We can also pass a custom message or rely on the default one, which you can find in theHTTPMessages custom class.

export class BadRequestException {
constructor(message = HTTPMessages.BAD_REQUEST) {
throw createError(HTTPStatusCode.BadRequest, message);
}
}

It holds string properties that contain default response messages. The reason messages are class properties and not enums is to allow the consumers to pass any string message they desire.

🟠 Using the HTTP Exception classes:

Now that we abstracted the createError() logic with a class, it will be easier for developers to consume it:

// ... some JS code
throw new BadRequestException();

// Or with custom message
throw new BadRequestException('Oooops!');

If we put this in an actual controller:

  .get('/', async (req: Request, res: Response) => {
try {
throw new BadRequestException('Oooops!');
const users = await UserModel.find({});
res.send(users);
} catch (ex: any) {
console.log('ex.message :>> ', ex.message);
console.log('ex.statusCode :>> ', ex.statusCode);
res.status(500).send({ message: ErrorMessages.GetFail });
}
})

Then run the code and go to the endpoint api/mongoose/users/ we should see a message printed in the console.

Error message and HTTP status code from the Exception class

Moreover, we can upgrade our response handler to use the proper message and status code coming from the exception or default to a generic one:

    } catch (ex: any) {
const statusCode = ex.statusCode || 500;
const message = ex.message ?? ErrorMessages.GetFail;

res.status(statusCode).send({ message });
}

The catch block contains an error that is of type unknown or any. To convert it to a proper error, I created IHTTPError interface that extends the JavaScript Error class:

export interface IHTTPError extends Error {
// because default Error class does not have status code
statusCode: number;
}

Now we can swap ex: any with an error:

    } catch (ex: unknown) {
const err = ex as IHTTPError;
const statusCode = err.statusCode || 500;
const message = err.message ?? ErrorMessages.GetFail;

res.status(statusCode).send({ message });
}

Now that we have a way of throwing HTTP exceptions outside the controller, we can move the business logic into services to keep the code better organized.

Separating Controllers and Services

In this module, we’ll reduce the controller logic and move the business logic part to the services. The idea behind this came from the Repository Pattern.
The Repository Pattern is commonly used to abstract the external APIs and data access logic in an application.

In our app,

  • The controllers will remain the main entry points for the clients
  • The client data is validated in the middlewares
  • The database queries will be handled in the services

Creating Services

A service can be a class with a set of methods, it can be a module with a set of exportable functions.

For now, we’ll create a set of non-related exportable singleton functions within a single file and refer to it as a service. All functions within the file will be related to a single entity (user or product).
Later, when we get to the logger we’ll build an actual class service, so you can see the combination of approaches.

🟠 User.service.ts

This service will contain business/database logic for creating, retrieving, updating, or deleting users.

// POST /api/mongoose/users
export const createNewUser = async (
userData: IUser
) => {
const newUser = new UserModel();
newUser.username = userData.username;
newUser.email = userData.email;
newUser.password = userData.password;

await newUser.save()
return newUser;
};

// GET /api/mongoose/users/:id
export const retrieveUserById = async (
id: string
) => {
const existingUser = await UserModel.findById(id);

if (!existingUser) {
throw new NotFoundException(`User with id: ${id} was not found!`);
}

return existingUser;
};

We’ll come back to this and add more error-handling logic.

Using DTOs

This is also a perfect opportunity to introduce Data-Transfer-Objects. Instead of returning the Mongoose or TypeORM response object directly to the client, we’ll map the response from the database into a new class known as DTO.
DTOs can be used to both map one type of object into a different form as well as redact unwanted properties.

🟠 user.dto.ts

import { IUser } from '../../../databases/mongodb/model/user.model';

export class UserResponseDTO {
id!: string;
username!: string;
email!: string;

// Mapper function
static toResponse(user: IUser): UserResponseDTO {
const userDTO = new UserResponseDTO();
userDTO.id = user._id;
userDTO.username = user.username;
userDTO.email = user.email;

return userDTO;
}
}

🟠 user.service.ts


// POST /api/mongoose/users
export const createNewUser = async (
userData: IUser
) => {

// ...

// map database response to the DTO
const userDTO = UserResponseDTO.toResponse(existingUser);
return userDTO;
};


// GET /api/mongoose/users
export const retrieveUsers = async (): Promise<UserResponseDTO[]> => {
const users = await UserModel.find({});

// map a list of Mongoose users to userDTO
const usersDTO = users.map((user) => UserResponseDTO.toResponse(user));
return usersDTO;
};


// GET /api/mongoose/users/:id
export const retrieveUserById = async (
id: string
): Promise<UserResponseDTO> => {

// ...

const userDTO = UserResponseDTO.toResponse(existingUser);
return userDTO;
};

Cleaning Controllers

With most of the business logic placed inside the services, we can clean the controllers.

🟠 user.controller.ts

import * as userService from '../../services/user/user.service';


// POST /api/mongoose/users
.post('/', createUserValidator, async (req, res) => {
try {
const newUser = await userService.createNewUser(req.body);
res.status(201).send(newUser);
} catch (ex: unknown) {
const error = ex as IHTTPError;
res.status(error.statusCode).send({ message: error.message });
}
})

// GET /api/mongoose/users
.get('/', async (req: Request, res: Response) => {
try {
const users = await userService.retrieveUsers();
res.send(users);
} catch (ex: unknown) {
const error = ex as IHTTPError;
const statusCode = error.statusCode || 500;
res.status(statusCode).send({ message: error.message });
}
})

// GET /api/mongoose/users/:id
.get('/:id', getUserByIdValidator, async (req: Request, res: Response) => {
try {
const existingUser = await userService.retrieveUserById(req.params.id);
res.send(existingUser);
} catch (ex: unknown) {
const error = ex as IHTTPError;
const statusCode = error.statusCode || 500;
res.status(statusCode).send({ message: ErrorMessages.GetFail });
}
})

Building Global Exception Handler (Middleware)

In Express.js we can create an error-handling middleware that will serve as a global spot for handling all errors produced from controllers.

In Express.js a middleware is a function that is executed in between a request and a response and takes three parameters:

  • Request
  • Response
  • Next

What makes the error-handling middleware distinctly different from the rest is that it has four parameters: error, request, response, and next.
This middleware is also always the last middleware in the router chain.

Error-handling middleware follows the Callback-Error-Handling pattern, where the first parameter is always an error, while the rest of the parameters are the usual three middleware parameters.

🟠 Exception handling middleware function

We’ll create a custom middleware function that will do all the work.

import { Request, Response, NextFunction } from 'express';
import { ErrorMessages } from '../enums/messages/error-messages.enum';
import { IHTTPError } from '../models/extensions/errors.extension';

export const exceptionHandler = (
error: IHTTPError,
req: Request,
res: Response,
_next: NextFunction // we won't be calling next() here
) => {
const statusCode = error.statusCode || 500;
const message = error.message || ErrorMessages.Generic;

// logger

return res.status(statusCode).send({ statusCode, message });
};

When an error is thrown, this error handler will catch it and return the status code and the exception message.
Applying it in the main router file:

 app 
.use(responseInterceptor)
.use('/api/mongoose/users', mongooseUsersRouter)
.use('/api/typeorm/products', typeormProductsRouter)

// ----> error-handling middleware <----
.use(exceptionHandler)

But even with global exception handling middleware in place, the errors will never reach the middleware. That’s because we’re already handling exceptions and sending the response to the client in the Try & Catch blocks.

Since this middleware will serve as the source of truth for all router exceptions, we’ll also add logger inside later so that we have one place for handling and recording failures.

What we need to do now is propagate (send) the exceptions from controllers to this exception-handling middleware.

Propagating Errors from Try-Catch to the Middleware

When the exception occurs, the program automatically exits out of the current context and is propagated up the stack trace back to the previous caller until it finds someone who can handle the exception. Even when caught, we can still manually propagate the error to where we wish to handle it.

As stated in the previous module, we’re still handling exceptions within the Try-Catch block and the middleware is doing nothing at the moment.

The first step in propagating errors is to use controller routes like middlewares. In Express a route handler can act like a middleware by using the third argument in the router function (the next() function).

Calling next() alone just executes the next middleware inline, but when calling it with a parameter, Express.js does error propagation from the current route to the next error handler:

  // POST /api/mongoose/users
.post('/', createUserValidator, async (req, res, next) => {
try {
const newUser = await userService.createNewUser(req.body);
res.status(201).send(newUser);
} catch (e: unknown) {
next(e); // <---- propagate error to the middleware
}
})

With this in place, if an exception occurs in this route, it will be sent (propagated) to the exception to the Global Exception handling middleware. Learn more about Exception Propagation.

Catching Errors on the Spot

When creating a new user a distinct set of errors can occur:

  • Either the database connection is broken and we’re unable to save new entries
  • Or, the same user has already been created and the database has a constraint that prevents us from creating an existing one

In case of a broken connection, we’re going to throw a 500 error, but in the case of the user already existing, I think a Conflict exception would be a better approach.

  try {
const newUser = new UserModel();
newUser.username = userData.username;
newUser.email = userData.email;
newUser.password = userData.password;

await newUser.save();

const userDTO = UserResponseDTO.toResponse(newUser);
return userDTO;
} catch (ex: unknown) {
const error = ex as IHTTPError;
throw ex; // propagate error to the next handler (middleware)
}

In order to see which error it was, we’d have to manually inspect ex.message and compare the output. However, there is an alternative to that approach.

We’ll wrap the users.save() into await-to-js function that returns a Promise error, similar to: return Promise.reject().catch(x => x); that allows us to wrap this with async await.

$ npm install await-to-js
// Basic implementation
const [error, data] = await to(newUser.save());

if (error) {
...
}

console.log(data);

Now let’s handle each scenario:

import to from 'await-to-js';  

const [error] = await to(newUser.save());

if (error && MongooseErrors.MongoServerError) {
// this conversion is needed because Error class does not
// have Mongoose error "code" property
const mongooseError = error as IMongooseError;

// check if there is a duplicate entry
if (mongooseError.code === MongooseErrorCodes.UniqueConstraintFail) {
throw new ConflictException(ErrorMessages.DuplicateEntryFail);
} else {
throw new InternalServerErrorException(ErrorMessages.CreateFail);
}
}

const userDTO = UserResponseDTO.toResponse(newUser);
return userDTO;

I created additional interfaces to make things cleaner:

export interface IMongooseError extends Error {
code: number;
}

export enum MongooseErrorCodes {
UniqueConstraintFail = 11000
}

export enum MongooseErrors {
MongoServerError = 'MongoServerError'
}

With this in place, we no longer need a Try-Catch block within services:


// POST /api/mongoose/users
export const createNewUser = async (
userData: IUser
): Promise<UserResponseDTO> => {
const newUser = new UserModel();
// ...

const [error] = await to(newUser.save());

if (error && MongooseErrors.MongoServerError) {

const mongooseError = error as IMongooseError;

// check if there is a duplicate entry
if (mongooseError.code === MongooseErrorCodes.UniqueConstraintFail) {
throw new ConflictException(ErrorMessages.DuplicateEntryFail);
} else {
throw new InternalServerErrorException(ErrorMessages.CreateFail);
}
}

const userDTO = UserResponseDTO.toResponse(newUser);
return userDTO;
};


// GET /api/mongoose/users
export const retrieveUsers = async (): Promise<UserResponseDTO[]> => {

const [error, users] = await to(UserModel.find({}));

// if get fails for whatever reason
if (error) {
throw new InternalServerErrorException(ErrorMessages.GetFail);
}

const usersDTO = users.map((user) => UserResponseDTO.toResponse(user));
return usersDTO;
};


// GET /api/mongoose/users/:id
export const retrieveUserById = async (
id: string
): Promise<UserResponseDTO> => {
const [error, existingUser] = await to(UserModel.findById(id));

// if user is not found, return 404
if (!existingUser) {
throw new NotFoundException(`User with id: ${id} was not found!`);
}

// if there is any other exception, throw 500
if (error) {
throw new InternalServerErrorException(ErrorMessages.GetFail);
}

const userDTO = UserResponseDTO.toResponse(existingUser);
return userDTO;
};

Getting Rid of Try-Catch

We’ve just proven that we can throw, but also handle HTTP errors (outside controllers) and propagate them to the error handler (middleware). The final step is to get rid of Try-Catch in controllers altogether.

The AsyncHandler package wraps routes (and middlewares) into an error wrapper that propagates errors to the next handler (in our case, the middleware).

$ npm install express-async-handler

With this in place, our controllers will focus solely on writing the happy path, while the exception path will be handled by the async handler.

🟠 Before

  // POST /api/mongoose/users
.post('/', createUserValidator, async (req, res, next) => {
try {
const newUser = await userService.createNewUser(req.body);
res.status(201).send(newUser);
} catch (e: unknown) {
next(e);
}
})

// GET /api/mongoose/users
.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const users = await userService.retrieveUsers();
res.send(users);
} catch (e: unknown) {
next(e);
}
})

🟠 After

import asyncHandler from 'express-async-handler';

// POST /api/mongoose/users
.post(
'/',
createUserValidator,
asyncHandler(async (req, res) => {
const newUser = await userService.createNewUser(req.body);
res.status(201).send(newUser);
})
)

// GET /api/mongoose/users
.get(
'/',
asyncHandler(async (req: Request, res: Response) => {
const users = await userService.retrieveUsers();
res.send(users);
})
)

If an exception occurs, it will be propagated to the Exception-Handling middleware (just like calling next(e) within Try-Catch).

Cleaning User Validation Middlewares

Previously we were validating users with the Joi package and handling Exceptions by sending a response directly from the middleware, but now we’ll propagate that to the Exception handler middleware.

🟠 Previous

export const getUserByIdValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {

if (!req.params?.id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}

await getUserIdValidationSchema.validateAsync(req.params);

next();
} catch (e: any) {
res.status(400).send({ message: e.message });
}
};

🟠 Improved (Async-to-js + throwable HTTP Errors)

export const getUserByIdValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.params?.id) {
throw new BadRequestException('Required parameter "id" is missing!');
}

const [error] = await to(getUserIdValidationSchema.validateAsync(req.params));

if (error) {
throw new BadRequestException(error.message);
}

next();
} catch (ex: unknown) {
const error = ex as IHTTPError;
error.statusCode = 400; // because ex does not have status code
next(error);
}
};

This condition and throw are essential to return the correct status code:

if (error) {
throw new BadRequestException(error.message);
}

Without it, the outcome of getUserIdValidationSchema.validateAsync(req.params) would be a validation error (not an HTTP one), thus the status code would be 200 with an error.

🟠 Latest (Async Handler)

export const getUserByIdValidator = asyncHandler(async (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params?.id)
throw new BadRequestException('Required parameter "id" is missing!');

const [error] = await to(getUserIdValidationSchema.validateAsync(req.params));

if (error)
throw new BadRequestException(error.message);

next();
});

Cleaning Product Validation Middlewares

The class-validator package used for validating products returns a ValidationError array, meaning that we’d have to do a little bit of extra work to handle errors.

I’ve also created a base validation class that all existing product validators inherit from:

export class BaseProductValidationSchema { }

export class CreateProductValidationSchema extends BaseProductValidationSchema implements IProduct { }

export class UpdateProductValidationSchema extends CreateProductValidationSchema { }

export class GetProductIdValidationSchema extends BaseProductValidationSchema { }

And a helper function (validateProduct()) that wraps the different validation variations:

async function validateProduct(product: BaseProductValidationSchema): Promise<void> {
const [error] = await to(validateOrReject(product));

if (error && error instanceof Array) {
const err: ValidationError[] = error;
const message = err[0].constraints ? Object.values(err[0].constraints)[0] : '';
throw new BadRequestException(message);
}
}

🟠 Implementing new product validation

export const createProductValidator = asyncHandler(async (
req: Request,
_: Response,
next: NextFunction
) => {
if (!req.body)
throw new BadRequestException('Missing request body!');

const product = new CreateProductValidationSchema();
product.name = req.body.name;
product.image = req.body.image;
product.price = req.body.price;
product.description = req.body.description;

// validate request body using await-to-js
await validateProduct(product);

// mapping CreateProductValidationSchema instance to ProductEntity instance
req.body = plainToClass(ProductEntity, product);

next();
});


export const getProductByIdValidator = asyncHandler(async (
req: Request,
_: Response,
next: NextFunction
) => {
if (!req.params?.id)
throw new BadRequestException('Required parameter "id" is missing!');

const product = new GetProductIdValidationSchema();
product.id = Number(req.params.id);

await validateProduct(product);

next();
});

Handling routes that do not exist

If we go to a URL that does not exist in our app, by default Express will respond with an HTML page that represents a not found page.
In these situations, we want to either display a not found message to the user or redirect to the home page.

We’ll do the former.

🟠 Implementing page not found handler

export const pageNotFoundExceptionHandler = (
_req: Request,
_res: Response,
_next: NextFunction
) => {
// this exception will be handled in the global exception middleware
throw new NotFoundException('Page not found!');
};

Adding the handler to the router:

... previous routes

// asterisk handles all request paths, but because the order maters,
// it will ignore route paths that came before

.use('*', pageNotFoundExceptionHandler) // <---- 404 page handler

// The exception handling middleware is the last one in the pipeline
.use(exceptionHandler)

Dealing with Uncaught Exceptions

When an exception occurs, either a callback, promise, or just a throwable Error, it is propagated from the current context (function) to the previous caller until it is handled. If there isn’t a handler for such an exception, the error will traverse up the stack trace to the start of the program and the server will crash.

For that reason, we use error-handling solutions, but what if something happens unexpectedly, sporadically, something that we didn’t count on? What if a library used in the project fails? Enter Uncaught Exceptions.

We can use the process global Node.js object to monitor these exceptions using the built-in exception events:

  • Uncaught Exception event
  • Unhandled Rejection event

We’ll put a process Node.js global object in the app.ts that will listen to these events. If either occurs, the process will handle the issue gracefully. In the app.ts file:

process.on('uncaughtException', (error: any) => {
console.error(error);
process.exit(1); // terminate the process with POSIX signal 1
});

process.on('unhandledRejection', (error: any) => {
console.error(error);
process.exit(1);
});

It’s a good practice to terminate the app if an unhandled exception occurs, log the output, and restart the process. The latter is done automatically if you’re app is using an orchestrator.

Updating Existing Logger

In the previous chapter, we learned how to make an automated logger. Now we’re going to improve it with a few fancy gimmicks.

🟠 Log Request object

export interface IHTTPLogMetaData {
req?: IExtendedRequest; // Express request minor modification
res?: Response; // Express Response
responseBody?: any;
error?: IHTTPError; // IHTTPError extends Error
customMetaData?: any; // anything you desire
}

🟠 Log Response

// Updated interface
export default interface IHTTPLoggerResponseData {
request: IHTTPLoggerRequest;
response: IHTTPLoggerResponse;
error: IHTTPLoggerError;
customMetaData?: any
}

🟠 Existing interfaces

interface IHTTPLoggerRequest {
headers: any;
host?: string;
protocol: string;
baseUrl: string;
url: string;
method: string;
body: any;
params: any;
query: any;
clientIp?: string | string[];
requestDuration: string;
}

interface IHTTPLoggerResponse {
headers: any;
statusCode: HTTPStatusCode;
body: any;
}

🟠 Error Interface (NEW)

interface IHTTPLoggerError {
name: string;
statusCode: HTTPStatusCode;
message: string;
stackTrace: string;
}

The updated logger now contains an error object with:

  • error name
  • HTTP status code
  • stack trace and
  • error message

New Logger Services

There is a whole new service that abstracts a lot of the log formatting logic behind it. So, instead of calling logger with a pair of functions each time:

 // previous logger usage
httpLogger.info(
getResponseMessage(req.method),
formatHTTPLoggerResponse(req, res, body, requestStartTime)
);

We’ll put this logic within the class methods we’ll invoke so that we (developers) are only concerned with passing the right data to the logger.
There are also a number of logger classes for each purpose:

  • Logging into CLI — CLILoggerService
  • Logging into files & CLI — HTTPLoggerService
  • Logging into database — DBLoggerService

🟠 Logger Interface

export interface IHTTPLoggerService {
/**
* Creates Info log using Winston
* @param {IHTTPLogMetaData} context - Holds current request & response info
* @param {string} message - Optional message
* @returns {Logger} Returns WInston Logger
*/
info(context: IHTTPLogMetaData, message: string): Logger;

/**
* Creates Warning log using Winston
* @param {IHTTPLogMetaData} context - Holds current request & response info
* @param {string} message - Optional message
* @returns {Logger} Returns Winston Logger
*/
warn(context: IHTTPLogMetaData, message: string): Logger;

/**
* Creates Error log using Winston
* @param {IHTTPLogMetaData} context - Holds current request & response info
* @param {string} message - Optional message
* @returns {Logger} Returns Winston Logger
*/
error(context: IHTTPLogMetaData, message: string): Logger;
}

🟠 Logger Service

We’ll implement the interface in our class.

class HTTPLoggerService implements IHTTPLoggerService {

info(context: IHTTPLogMetaData, message = '') {
return httpLogger.info(
message || getSuccessfulHTTPResponseMessage(context?.req?.method as HTTPMethods),
formatHTTPLoggerResponse(context)
);
}

warn(context: IHTTPLogMetaData, message = '') {
return httpLogger.warn(
message || getUnSuccessfulHTTPResponseMessage(context?.req?.method as HTTPMethods),
formatHTTPLoggerResponse(context)
);
}

error(context: IHTTPLogMetaData, message = '') {
return httpLogger.error(
message || getUnSuccessfulHTTPResponseMessage(context?.req?.method as HTTPMethods),
formatHTTPLoggerResponse(context)
);
}

}

export const httpLoggerService = new HTTPLoggerService();

Then export the class instance and use it throughout the app.

Using new Loggers

Previously we logged all types of exceptions in the response interceptor. Now we’ll make a slight change to the formula. We’ll log only the successful response in the interceptor:

  res.send = function (responseBody: any): Response {

if (!responseSent) {

if (res.statusCode < HTTPStatusCode.BadRequest) {
httpLoggerService.info({ req, res, responseBody })
dbLoggerService.info({ req, res, responseBody });
}
responseSent = true;
}

return originalSend.call(this, responseBody);
};

While the failed ones will be logged within the Exception middleware.

export const exceptionHandler = (
error: IHTTPError,
req: Request,
res: Response,
_next: NextFunction
) => {

const statusCode = error.statusCode || 500;
const message = error.message || ErrorMessages.Generic;

// req => request sent from the client
// res => response (headers)
// error => error thrown by async-to-js or other
httpLoggerService.error({ req, res, error });
dbLoggerService.error({ req, res, error });

return res
.status(statusCode)
.send({ statusCode, message });
};

Wrapping Exception Logs

If an application fails to start or an uncaught exception is thrown during runtime, we do not want to constantly convert an unknown exception into an Error class and then invoke loggers.

Instead, we can extract this common logic into a helper:

export const exceptionLogWrapper = (
error: unknown,
globalMessage: ErrorMessages
) => {
const err = error as Error;
cliLoggerService.error('Server startup failed! ❌');
httpLoggerService.error(
{
error: {
name: HTTPMessages.INTERNAL_SERVER_ERROR,
statusCode: 500,
message: err.message,
stack: err.stack,
},
},
globalMessage
);
};

And reuse it in multiple places:

🟠 App Startup (init.ts)

const appSetup = async (app: Express) => {
try {
await Promise.all([typeORMConnect(), mongooseConnect()]);

cliLoggerService.info(InfoMessages.DatabasesConnected);
cliLoggerService.info(SpecialMessages.DottedLine);
const PORT = Number(process.env.PORT) || 3000;

app.listen(PORT, () => {
cliLoggerService.info(`Server started on port ${PORT} 🚀🚀🚀`);
});
} catch (error: unknown) {
exceptionLogWrapper(error, ErrorMessages.AppStartupFail); // <----
}
};

🟠 Unhandled Exceptions (app.ts)

process.on(NodeProcessEvents.UncaughtException, (error: unknown) => {
exceptionLogWrapper(error, ErrorMessages.UncaughtException);
process.exit(1);
});

process.on(NodeProcessEvents.UnhandledRejection, (error: unknown) => {
exceptionLogWrapper(error, ErrorMessages.UnhandledRejection);
process.exit(1);
});

Error Log Preview

Preview of the new log output

Last, but not least, we’ll manually test the application using Postman to make sure existing features are still intact and proper messages logged.

#1 Middleware/Database validation

🟠 Request

// GET /api/mongoose/users/123 

Response (HTTP Status 400)

{
"statusCode": 400,
"message": "\"id\" length must be 24 characters long"
}

🟠 Request

// GET /api/mongoose/users/999999999999999999999999

Response (HTTP Status 404)

{
"statusCode": 404,
"message": "User with id: 999999999999999999999999 was not found!"
}

#2 Duplicate Entry

🟠 Initial Request

// POST /api/mongoose/users/
{
"username": "HomerSimpson",
"email": "homer@doe.com",
"password": "hello",
"repeat_password": "hello"
}

Response (HTTP Status 201)

{
"id": "655e6366283adde00d29d255",
"username": "HomerSimpson",
"email": "homer@doe.com"
}

🟠 Subsequent Request

// POST /api/mongoose/users/
{
"username": "HomerSimpson",
"email": "homer@doe.com",
"password": "hello",
"repeat_password": "hello"
}

Response (HTTP Status 409)

{
"statusCode": 409,
"message": "User already exists!"
}

🟠 Logger Output

 {
"level": "error",
"logId": "15f00f92ed477aec859bc599985ca166",
"timestamp": "Nov-22-2023 21:33:23",
"appInfo": {
"appVersion": "1.0.0",
"proccessId": 21024
},
"message": "Unable to save entry to DB!",
"data": {
"request": {
"headers": {... },
"host": "localhost:3000",
"protocol": "http",
"baseUrl": "",
"url": "/api/mongoose/users/",
"method": "POST",
"body": {
"username": "HomerSimpson",
"email": "homer@doe.com",
"password": "*****",
"repeat_password": "*****"
},
...
"requestDuration": "0.069s"
},
"response": {
"headers": {
"x-powered-by": "Express",
"access-control-allow-origin": "*"
},
"statusCode": 409
},
"error": {
"name": "ConflictError",
"statusCode": 409,
"message": "User already exists!",
"stackTrace": "ConflictError: User already exists!\n"
}

#3 Database Exceptions

Connection Issues

If we force database failure:

export default async function typeORMConnect(): Promise<void> {
throw new Error('TypeORM connection failure!');
...
}

export default async function mongooseConnect(): Promise<void> {
throw new Error('Mongoose connection failure!');
...
}

These will be caught by Try-Catch in the init.ts file and handled by the exceptionLogWrapper() mentioned earlier and logged below:

{
"level": "error",
"logId": "bf46c6985ec667623a1c4bab74bd3346",
"timestamp": "Nov-22-2023 15:24:26",
"appInfo": {
"appVersion": "1.0.0",
"proccessId": 28736
},
"message": "Unable to start the app!",
"data": {
"request": {
"requestDuration": "."
},
"response": {
"statusCode": 500
},
"error": {
"name": "Internal Server Error",
"statusCode": 500,
"message": "Mongoose connection failure!",
"stackTrace": "Error: Mongoose connection failure!\n..."
}
}
}

This will also work if you mistype the database connection URL:

        "error": {
"name": "Internal Server Error",
"statusCode": 500,
"message": "connect ECONNREFUSED ::1:27017",
...
}

🟠 Database query fails

If an ORM throws an error while attempting to execute the query,

export function useTypeORM(
entity: EntityTarget<ObjectLiteral>
): Repository<ObjectLiteral> {
throw new Error('TypeORM has not been initialized!');
}

It should be caught by our global error-handling middleware.

#4 Handling Unexpected Exceptions

If an error is thrown while starting the app,

throw new Error('This is an unhandled exception!');

It should be handled by the process object listening for uncaught exceptions. The log will be generated in the exceptionLogWrapper() function and we’ll see a similar output as above:

      "error": {
"name": "Internal Server Error",
"statusCode": 500,
"message": "This is an unhandled exception!",
...
}

After which process.exit(1) will kick in and terminate the process:

[nodemon] app crashed - waiting for file changes before starting...

#5 Invalid Route Requested

Request

// GET /api/non-existing-route

Response (HTTP Status 404)

{
"statusCode": 404,
"message": "Page not found!"
}

--

--

Mirza Leka

Web Developer. DevOps Enthusiast. I share my experience with the rest of the world. Follow me on https://twitter.com/mirzaleka for news & updates #FreePalestine