Creating a Node.js Error-handling System

It’s clear that some struggle with, or even completely neglect, proper error handling. Yet, it’s crucial not only for faster development through easier bug detection but also for building robust large-scale applications.

Node.js developers, in particular, often grapple with messy code and inconsistent error handling, leading them to question Node.js’s error-handling capabilities. However, the responsibility lies with us developers, not the framework itself.

Here’s a solution I find particularly effective.

Node.js Error-handling: Error Types

A clear understanding of Node.js errors is paramount. They fall into two categories: operational errors and programmer errors.

  • Operational errors are expected runtime issues requiring proper handling. They don’t indicate application flaws but necessitate careful management. Examples include “out of memory” or invalid API endpoint inputs.
  • Programmer errors are unexpected bugs stemming from flawed code, signaling issues within the code itself. An example is attempting to access a property of “undefined.” Fixing this requires code modification – it’s a developer error, not an operational one.

Distinguishing these is crucial: operational errors are inherent, while programmer errors are developer-induced bugs. This distinction begs the question: “Why differentiate and handle them differently?”

Without this clarity, you might consider restarting your application for every error. But restarting due to a “File Not Found” error while thousands are actively using the application is illogical.

Conversely, allowing an application to run with an unknown bug that could snowball into a larger issue is equally unwise!

Towards Proper Error Handling

If you’ve worked with async JavaScript and Node.js, you’ve likely encountered the drawbacks of callback-based error handling. Nested callbacks lead to the infamous “callback hell,” hindering code readability.

Promises or async/await offer a better alternative. Here’s a typical async/await flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const doAsyncJobs = async () => {
 try {
   const result1 = await job1();
   const result2 = await job2(result1);
   const result3 = await job3(result2);
   return await job4(result3);
 } catch (error) {
   console.error(error);
 } finally {
   await anywayDoThisJob();
 }
}

Leveraging Node.js’s built-in Error object is recommended, as it provides insightful information like the StackTrace, invaluable for tracing error origins. Extending the Error class with properties like HTTP status codes and descriptions enhances clarity further.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class BaseError extends Error {
 public readonly name: string;
 public readonly httpCode: HttpStatusCode;
 public readonly isOperational: boolean;
 
 constructor(name: string, httpCode: HttpStatusCode, description: string, isOperational: boolean) {
   super(description);
   Object.setPrototypeOf(this, new.target.prototype);
 
   this.name = name;
   this.httpCode = httpCode;
   this.isOperational = isOperational;
 
   Error.captureStackTrace(this);
 }
}

//free to extend the BaseError
class APIError extends BaseError {
 constructor(name, httpCode = HttpStatusCode.INTERNAL_SERVER, isOperational = true, description = 'internal server error') {
   super(name, httpCode, isOperational, description);
 }
}

For brevity, I’ve included only a few HTTP status codes, but feel free to add more.

1
2
3
4
5
6
export enum HttpStatusCode {
 OK = 200,
 BAD_REQUEST = 400,
 NOT_FOUND = 404,
 INTERNAL_SERVER = 500,
}

While extending BaseError or APIError isn’t mandatory, it can be beneficial for common errors, based on your needs.

1
2
3
4
5
class HTTP400Error extends BaseError {
 constructor(description = 'bad request') {
   super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description);
 }
}

Here’s how to use it:

1
2
3
4
5
6
7
8
9
...
const user = await User.getUserById(1);
if (user === null)
 throw new APIError(
   'NOT FOUND',
   HttpStatusCode.NOT_FOUND,
   true,
   'detailed explanation'
 );

Centralized Node.js Error-handling

Now, let’s build the core of our Node.js error-handling system: the centralized error-handling component.

A centralized component prevents code duplication during error handling. It interprets caught errors by, for example, notifying admins, pushing events to services like Sentry.io, and logging them.

Here’s a basic error-handling workflow:

Error handling in Node.js: basic workflow

In certain code sections, caught errors are passed to an error-handling middleware.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
...
try {
 userService.addNewUser(req.body).then((newUser: User) => {
   res.status(200).json(newUser);
 }).catch((error: Error) => {
   next(error)
 });
} catch (error) {
 next(error);
}
...

This middleware acts as a triage, differentiating error types and routing them to the centralized error-handling component. Familiarity with Express error-handling basics](https://expressjs.com/en/guide/error-handling.html) is beneficial here, as is a deeper dive into best practices with Promises.

1
2
3
4
5
6
app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => {
 if (!errorHandler.isTrustedError(err)) {
   next(err);
 }
 await errorHandler.handleError(err);
});

By now, you likely envision the centralized component’s structure, as we’ve used some of its functionalities. Implementation is flexible, but here’s a potential structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class ErrorHandler {
 public async handleError(err: Error): Promise<void> {
   await logger.error(
     'Error message from the centralized error-handling component',
     err,
   );
   await sendMailToAdminIfCritical();
   await sendEventsToSentry();
 }
 
 public isTrustedError(error: Error) {
   if (error instanceof BaseError) {
     return error.isOperational;
   }
   return false;
 }
}
export const errorHandler = new ErrorHandler();

The default “console.log” output can be difficult to parse. A formatted output is more effective, allowing developers to swiftly understand and address issues.

This streamlined approach saves time and enhances error visibility and management. Employing a customizable logger like winston or morgan is a wise choice.

Here’s a custom Winston logger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
const customLevels = {
 levels: {
   trace: 5,
   debug: 4,
   info: 3,
   warn: 2,
   error: 1,
   fatal: 0,
 },
 colors: {
   trace: 'white',
   debug: 'green',
   info: 'green',
   warn: 'yellow',
   error: 'red',
   fatal: 'red',
 },
};
 
const formatter = winston.format.combine(
 winston.format.colorize(),
 winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
 winston.format.splat(),
 winston.format.printf((info) => {
   const { timestamp, level, message, ...meta } = info;
 
   return `${timestamp} [${level}]: ${message} ${
     Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
   }`;
 }),
);
 
class Logger {
 private logger: winston.Logger;
 
 constructor() {
   const prodTransport = new winston.transports.File({
     filename: 'logs/error.log',
     level: 'error',
   });
   const transport = new winston.transports.Console({
     format: formatter,
   });
   this.logger = winston.createLogger({
     level: isDevEnvironment() ? 'trace' : 'error',
     levels: customLevels.levels,
     transports: [isDevEnvironment() ? transport : prodTransport],
   });
   winston.addColors(customLevels.colors);
 }
 
 trace(msg: any, meta?: any) {
   this.logger.log('trace', msg, meta);
 }
 
 debug(msg: any, meta?: any) {
   this.logger.debug(msg, meta);
 }
 
 info(msg: any, meta?: any) {
   this.logger.info(msg, meta);
 }
 
 warn(msg: any, meta?: any) {
   this.logger.warn(msg, meta);
 }
 
 error(msg: any, meta?: any) {
   this.logger.error(msg, meta);
 }
 
 fatal(msg: any, meta?: any) {
   this.logger.log('fatal', msg, meta);
 }
}
 
export const logger = new Logger();

This logger provides multi-level logging with formatting and colors, outputting to different media based on the runtime environment. It allows log viewing and querying through Winston’s APIs. You can even integrate log analysis tools for further insights. Powerful, right?

So far, we’ve focused on operational errors. What about programmer errors? The optimal approach here is an immediate crash followed by a graceful restart using an auto-restarter like PM2. Why? Because these are unexpected bugs that can leave the application in an unpredictable, potentially harmful state.

1
2
3
4
5
6
process.on('uncaughtException', (error: Error) => {
 errorHandler.handleError(error);
 if (!errorHandler.isTrustedError(error)) {
   process.exit(1);
 }
});

Finally, let’s address unhandled promise rejections and exceptions.

Promise handling is common in Node.js/Express applications. Forgetting to handle rejections often leads to “unhandled promise rejection” warnings.

These warnings merely log the issue. A better practice is to implement a robust fallback and subscribe to process.on(‘unhandledRejection’, callback), acting as a global Node.js error handler.

A typical error-handling flow could look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// somewhere in the code
...
User.getUserById(1).then((firstUser) => {
  if (firstUser.isSleeping === false) throw new Error('He is not sleeping!');
});
...
 
// get the unhandled rejection and throw it to another fallback handler we already have.
process.on('unhandledRejection', (reason: Error, promise: Promise<any>) => {
 throw reason;
});
 
process.on('uncaughtException', (error: Error) => {
 errorHandler.handleError(error);
 if (!errorHandler.isTrustedError(error)) {
   process.exit(1);
 }
});

Error-handling in Node.js: Essential, Not Optional

Error-handling is not optional – it’s fundamental for development and production.

Centralizing error handling in Node.js saves time and promotes clean, maintainable code by minimizing duplication and preserving error context.

I hope this article provided valuable insights and that the discussed workflow and implementation help you build robust Node.js applications.

Licensed under CC BY-NC-SA 4.0