Typescript Meaningful Exception Handling

In this article, we will go to create meaningful exceptions names for Node & typescript & express application with global error handler middleware. Bit by bit, we will learn how to create custom errors and express middleware to handle descriptive and meaningful name errors. You will be able to capture and handle errors more effectively and, above all, learn what are the pros and cons of using meaningful exception names.

Let's make a fictitious situation:

In a software development company, a team of developers is working on a new e-commerce platform for a client. The developers are tasked with creating a number of features and functionalities for the website, including payment processing, user authentication, and stock availability. However, as the project moves on, the team starts to encounter numerous bugs and errors in their code, making it difficult to identify and address the root causes of these issues.

One of the biggest causes of these issues is because developers do not provide significant exceptions in their code. Instead, they are relying on generic error messages or not handling errors at all.

Realizing the importance of using meaningful exceptions, we should implement a more robust exception handling mechanism and introduce custom errors which allows us to identify and handle errors more effectively.

Given the above, let’s code the project.

Setting up the project

Firstly, we have to create our Node project:

npm init --yes # this will trigger automatically populated initialization with default values

Then, install the project's dependencies which are necessary to work with “Typescript” and “express”.

npm install express
# Dev dependencies
npm install --save-dev @types/express @types/node typescript ts-node ts-node-dev

Project structure

Add the following files and folders in your local project folder:

.
├── src
│   ├── errors
│   │   ├── base.error.ts
│   │   ├── insufficient-funds.error.ts
│   │   ├── types.ts
│   │   └── internal-server.error.ts
│   ├── middlewares
│   │   └── error.middleware.ts
│   └── server.ts
└── tsconfig.json

Typescript configurations

To setup typescript properly, add the following settings to the tsconfig.json file:

{
  "compilerOptions": {
    "target": "es2020",
    "outDir": "./dist",
    "rootDir": "src",
    "moduleResolution": "node",
    "module": "commonjs",
    "declaration": true,
    "inlineSourceMap": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "types": ["node"],
    "typeRoots": ["node_modules/@types"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules/**"],
}

Types

We are going to define a set of types which helps us to add additional properties and methods for our custom errors. Add the following content into types.ts file:

export type ErrorPayload = {
  message: string;
  details: string | null;
};

export type ErrorOptions = {
  httpCode: number;
  payload: ErrorPayload;
};

export type ErrorResponse = {
  httpCode: number;
  error: {
      name: string;
      stack: Array<string> | string;
  } & ErrorPayload;
};

Custom Errors

BaseError

As we already have Error types defined, we will create a base class for custom errors that extends the javascript built-in Error class. This base class would serve as a template for creating specific custom error classes that can be used throughout the application. Add the following content into base.error.ts file:

import { ErrorOptions, ErrorResponse } from "./types";

export abstract class BaseError extends Error {
  constructor(private readonly options: ErrorOptions) {
    super();
    this.name = this.constructor.name;
  }

  toHttpResponse(): ErrorResponse {
    const { httpCode, payload } = this.options;
    return {
      httpCode,
      error: {
        name: this.name,
        ...payload,
        stack: (this.stack ?? "Stack not available").split("\n").map((item: string) => item.trim())
      },
    };
  }
}

The class BaseError extends the built-in Error class. It takes an ErrorOptions object as a parameter in its constructor. The constructor sets the name property of the error to the constructor's name.

The toHttpResponse method is defined to convert the error object into an ErrorResponse object which includes the HTTP code and error details. It then returns an object with httpCode, error object containing the error name, payload, and a stack trace (if available).

Now, we can create specific custom error classes by extending the “BaseError” class.

Custom InsufficientFundsError

This custom error will represent a business rule for payment processing feature for those cases when the bank decline charge because the customer does not have available balance. Add the following content into insufficient-funds.error.ts:

import { BaseError } from "./base.error";
import { ErrorPayload } from "./types";

export class InsufficientFundsError extends BaseError {
  constructor(payload: ErrorPayload) {
    super({
      httpCode: 400,
      payload,
    });
  }
}

As the BaseError constructor expect an httpCode attribute we will use the value 400 which will represent the Bad Request http status code.

Custom InternalServerError

And we will create a default custom error which handle all of those exceptions that are not instance of BaseError. For this new custom error we will use the value 500 to represents the Internal Server Error http status code. Add the following content into internal-server.error.ts:

import { BaseError } from "./base.error";

export class InternalServerError extends BaseError {
  constructor(error: Error) {
    super({
      httpCode: 500,
      payload: {
        message: error.name,
        details: error.message,
      },
    });
  }
}

Express Error Middleware

Then, add the following content into the error.middleware.ts file:

import { Express, NextFunction, Request, Response } from "express";
import { BaseError } from "../errors/base.error";
import { InternalServerError } from "../errors/internal-server.error";

export class ErrorMiddleware {
    static handle(error: Error,
        req: Request,
        res: Response,
        next: NextFunction): void {

        let _error = <BaseError>error;

        if (!(error instanceof BaseError)) {
            _error = new InternalServerError(error);
        }

        const { httpCode, ...rest } = _error.toHttpResponse();

        res.status(httpCode).json({ ...rest });
    }
}

The ErrorMiddleware class contains a static method called handle. This method takes in four parameters according to the express error handler signature: error, request, response, and next. It first checks if the error is an instance of BaseError, if not, it creates a new instance of InternalServerError using the original error message.

It then extracts the httpCode and other properties from the error object using the toHttpResponse method of the BaseError class. Finally, it uses the extracted httpCode to sets the HTTP status code of the response and sends a JSON response with the remaining properties of the error object. The error object has the following properties:

  • name: This indicates the name.

  • message: This is a specific error message.

  • details: This provides more specific details about the error

  • stack: This is an array of stack trace information showing the sequence of function calls that led to the error. It includes information about the file path, line number, and function where the error occurred.

Express Server

Next, add the following content into the server.ts file:

import express, { Request, Response } from "express";
import { InsufficientFundsError } from "./errors/insufficient-funds.error";
import { ErrorMiddleware } from "./middlewares/error.middleware";

const app = express();
const port = 9000;

app.use(express.json());
// Payments route
app.post("/payments", (req: Request, res: Response) => {
    const amount = +req.body.amount;
    if (amount >= 100)
        throw new InsufficientFundsError({ 
            message: "Decline charge",
            details: "Your available balance is less than your ledger balance"
        });
    else
        res.send("Allow to take money out of account...");
});
// Orders route
app.get("/orders/:id", (req: Request, res: Response) => {
    const id = +req.params.id;
    if (!id)
        throw new Error("Id not found");
    else
        res.send([]);
});

// Error handler
app.use(ErrorMiddleware.handle);

app.listen(port, () => {
  console.log(`⚡️[server]: Server is running at http://localhost:${port}`);
});

Let’s explain the server:

  • We have an Express server running on port 9000.

  • We have a POST route for making payments and a GET route for retrieving orders.

  • If the payment amount is greater than 100, we throw an InsufficientFundsError.

  • If the order ID is not provided, we throw a generic Error.

  • We have an error handler middleware to handle any errors thrown in the routes.

  • If an error is encountered, the error handler middleware will handle the error and send an appropriate http response.

Test API

Try to pay an order above 100

Remember “If the payment amount is greater than 100, we throw an Insufficient Funds Error

curl  -X POST \
  'http://localhost:9000/payments' \
  --header 'Accept: */*' \
  --header 'User-Agent: SenorDeveloper (https://www.maxmartinez.dev)' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "amount": 200
}'

Response

Status: 400 Bad Request

{
  "error": {
    "name": "InsufficientFundsError",
    "message": "Decline charge",
    "details": "Your available balance is less than your ledger balance,",
    "stack": [
      "InsufficientFundsError:",
      "at /Users/xxx/hashnode-blog/meaningful-exceptions/src/server.ts:12:15",
      "at Layer.handle [as handle_request] (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/layer.js:95:5)",
      "at next (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/route.js:149:13)",
      "at Route.dispatch (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/route.js:119:3)",
      "at Layer.handle [as handle_request] (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/layer.js:95:5)",
      "at /Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:284:15",
      "at Function.process_params (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:346:12)",
      "at next (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:280:10)",
      "at /Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/body-parser/lib/read.js:137:5",
      "at AsyncResource.runInAsyncScope (node:async_hooks:206:9)"
    ]
  }
}

The http response represents an error object with the following properties:

  • name: It indicates the type of error which is "InsufficientFundsError".

  • message: It describes the error message as "Decline charge".

  • details: It provides more information about the error, stating that the “available balance is less than the ledger balance”.

  • stack: It is an array that contains the stack trace of the error, showing the sequence of function calls that led to the error.

Retrieving an order with a not valid id

curl  -X GET \
  'http://localhost:9000/orders/0' \
  --header 'Accept: */*' \
  --header 'User-Agent: SenorDeveloper (https://www.maxmartinez.dev)' \
  --header 'Content-Type: application/json'

Response

Status: 500 Internal Server Error

{
  "error": {
    "name": "InternalServerError",
    "message": "Error",
    "details": "Id not found",
    "stack": [
      "InternalServerError:",
      "at handle (/Users/xxx/hashnode-blog/meaningful-exceptions/src/middlewares/error.middleware.ts:14:22)",
      "at Layer.handle_error (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/layer.js:71:5)",
      "at trim_prefix (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:326:13)",
      "at /Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:286:9",
      "at Function.process_params (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:346:12)",
      "at next (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/index.js:280:10)",
      "at next (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/route.js:141:14)",
      "at Layer.handle [as handle_request] (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/layer.js:97:5)",
      "at next (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/route.js:149:13)",
      "at Route.dispatch (/Users/xxx/hashnode-blog/meaningful-exceptions/node_modules/express/lib/router/route.js:119:3)"
    ]
  }
}

As the Orders route does not implement any custom exception it triggers a generic error: throw new Error("Id not found"); for this case, the error middleware identify that the Error is not an instance of BaseError and creates a new instance of InternalServerError using the original error message.

Conclusion

A key component of software development that enables programmers to deal with mistakes and unexpected situations is exception handling. But many developers frequently forget how important it is to use meaningful exceptions in their code. Instead, they are relying on generic error messages or not handling errors at all, which makes it challenging for them to precise the exact cause of the issues.

Pros of using meaningful exception names

  • Improved code readability and maintainability

  • Quicker debugging and troubleshooting

  • Enhanced team communication

  • Assistance in identifying and resolving possible issues more quickly

Cons of using meaningful exception names

  • Additional work required to define and manage custom error classes

  • Potential overkill for straightforward or small-scale applications

  • Possibly revealing vulnerabilities that can be exploited.

What we should not do

  • Do not expose detailed error messages to end-users. This can potentially expose sensitive information and increase the risk of security breaches.

  • For security validation, don't depend just on error notifications. Error messages can help attackers exploit vulnerabilities, thus they shouldn't be utilised to validate user input or give them feedback.

  • Don't overlook proper error handling and logging practices. Strong error-handling procedures must be in place to safeguard the application's security and stop sensitive data from being revealed.

Finally, meaningful exception names can improve communication within a development team or with external users and, also improve information that we can provide for logging practices. However, exposing detailed data about errors through meaningful exception names can pose several security risks.

Now that you’ve read this article, you know exactly how to deal with exceptions more effectively and what are the drawbacks of exposing detailed errors.

Subscribe to my newsletter and don't miss new articles.

See you then.

0
Subscribe to my newsletter

Read articles from Max Martínez Cartagena directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Max Martínez Cartagena
Max Martínez Cartagena

I'm an enthusiastic Chilean software engineer in New Zeland. I mostly focus on the back-end of the systems. This is my site, Señor Developer, where I share my knowledge and experience.