Skip to content

How to Handle Errors in Express with TypeScript

Express/

Error handling is a crucial part of any production-ready application. It’s often neglected when you are exploring project ideas and trying to learn. But you should never forget about it when you launch your project for public use.

Your users deserve to have the best experience and receive useful error messages. And you deserve the peace of mind that your application can handle all kinds of errors.

When you decide to handle errors yourself, you can add useful information to them, such as HTTP response status codes. You can separate critical errors from those caused by users. Doing error handling yourself gives you options like error logging or sending yourself an email.

In this post you will learn about the many parts of error handling, such as the following:

  • catching all types of errors
  • funneling all errors into a single error handler
  • creating your own error-handling middleware
  • setting up a custom error class
  • implementing the error handler to process all errors

To just see the code, visit this demo repository.

Prerequisites

You should have an Express application set up with TypeScript.

These are the dependencies used in this post.

npm i express
npm i @types/express @types/node ts-node-dev typescript --save-dev

Catching Errors

To handle errors, you first must catch them. You could program the best error handler, but it wouldn’t matter if some errors would escape it.

Express can catch all synchronous errors and send them to its error-handling middleware.

To verify, try throwing an error in one of your routes.

router.get('/', (req: Request, res: Response) => {
  throw new Error('This is an error');

  res.json({ status: 'ok' });
});

Visit the / route in your browser and you should see an error.

Error: This is an error
    at /path/to/project/src/routes.ts:6:9
    ...

Express includes the stack trace in the error message you see in your browser. If you set Node environment to production (NODE_ENV=production), Express will hide the stack trace.

However, errors thrown in asynchronous code can go unnoticed by Express.

Catching Errors in Asynchronous Code

As just mentioned, Express doesn’t catch errors thrown in asynchronous code. Unless you are from the future and using Express 5. You can skip this section then.

For those stuck with Express 4, consider the following code that throws an error inside a promise. Something that could happen when reading user data from a database.

const getUserFromDb = () => {
  return new Promise(() => {
    throw new Error('This is an error');
  });
};

router.get('/', async (req: Request, res: Response) => {
  const data = await getUserFromDb();

  res.json({ user: data });
});

If you revisit the / route, the page won’t load and you will need to restart your application.

You need to catch errors in async code yourself. Otherwise Express won’t pass them to the default error-handling middleware. You can use try/catch and the NextFunction to do that.

import { NextFunction, Request, Response } from 'express';

router.get('/', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const data = await getUserFromDb();

    res.json({ user: data });
  } catch (error) {
    next(error);
  }
});

Notice how you pass the caught error to next function. Any argument you pass to next, Express will treat as an error. By calling next(error), you jump from any middleware straight to the error-handling middleware.

The problem with try/catch approach is having to do it tens or hundreds of times as your app grows. It is repetitive, hence error-prone.

You can use a package dedicated to catching async errors for you. One such package is express-async-errors.

Install it as a dependency.

npm i express-async-errors

And import it before you register any router handlers.

// src/routes.ts

import 'express-async-errors';
import { NextFunction, Request, Response, Router } from 'express';

const router = Router();

const getUserFromDb = () => {
  return new Promise(() => {
    throw new Error('This is an error');
  });
};

router.get('/', async (req: Request, res: Response) => {
  const data = await getUserFromDb();

  res.json({ user: data });
});

export default router;

The express-async-errors package will make sure async errors in your routes get caught. And Express will be able to handle these errors.

So far you have learned that Express can catch errors in synchronous code and how to help it catch async errors. What about errors that Express doesn’t notice? You need to deal with unhandled rejections and uncaught exceptions yourself.

Dealing With Unhandled and Uncaught Errors

Sometimes an error goes unnoticed and Express doesn’t handle it. When Node.js encounters such errors, it emits events that you can listen to.

The first event you need to listen to is unhandledRejection. It happens when your code doesn’t handle a rejected Promise with a .catch().

const getUserFromDb = async () => {
  return new Promise((_, reject) => {
    reject('This is an error');
  });
};

router.get('/', async (req: Request, res: Response) => {
  getUserFromDb()
    .then(value => {
      res.json(value);
    })
});

Since this code doesn’t handle rejections from getUserFromDb with a .catch(), an UnhandledPromiseRejection warning shows up in Node.js console.

To fix it, you can register a listener function on the process object that handles the unhandledRejection event.

// src/process.ts

process.on('unhandledRejection', (reason: Error | any) => {
  console.log(`Unhandled Rejection: ${reason.message || reason}`);

  throw new Error(reason.message || reason);
});

Since rejections are used to return errors from Promises, you can throw an error with the given reason from the rejection.

In this case, Express won’t handle the thrown error anyway. You have created an uncaught exception. Any errors in your code base, that Express can’t handle, turn into uncaught exceptions. Luckily, you can catch them errors yourself.

To catch uncaught exceptions, listen to the uncaughtException event by setting up an event listener on the process object.

// src/process.ts

process.on('uncaughtException', (error: Error) => {
  console.log(`Uncaught Exception: ${error.message}`);

  errorHandler.handleError(error);
});

Here you want to funnel the errors into a function that handles them. Later in this post you will learn how to implement the handleError function.

You can crash your application by calling process.exit(1). Set up an automatic restart mechanism when your application exits with a non-zero code.

Don’t forget to import the code that registers the process event listener functions into your app.

// index.ts

import express, { Application } from 'express';
import './src/process';

You learned how to funnel uncaught errors into your own error handler. The errors caught in middleware functions are still handled by Express. If you want to handle them yourself, you need to create your own error-handling middleware.

Creating a Custom Error-Handling Middleware

To override default Express error responses, you need to create your own error-handling middleware.

An error-handling middleware differs from other middleware functions by having 4 parameters — err, req, res, next. Additionally, it has to be the last middleware you set up in your application. For this reason, call app.use(router) after all other app.use() calls in your index.ts. And register the error-handling middleware after registering other route handlers.

Now go ahead and register your custom error-handling middleware.

// src/routes.ts

import { NextFunction, Request, Response, Router } from 'express';
import { errorHandler } from './exceptions/ErrorHandler';

// ... all other routes

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  errorHandler.handleError(err, res);
});

Similarly to how you previously handled uncaught exceptions, you can funnel all errors into errorHandler, which you will get to implement very soon. You should also pass the Response object to the error handler so you can use it to send a response.

You can have more than one error-handling middleware. To pass the error from one to another, call the next function and pass it the error.

// ... all other routes

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 1. Log the error or send it to a 3rd party error monitoring software
  logger.logError(err);

  next(err);
});

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 2. Send an email to yourself, or a message somewhere
  messenger.sendErrorMessage(err);

  next(err);
});

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 3. Lastly, handle the error
  errorHandler.handleError(err, res);
});

The error will flow through the error-handling middleware functions from first to last (1-2-3).

The last error-handling middleware should send a response, so the client’s connection doesn’t hang up. In this case, handleError should send a response through the res argument.

You can also call next(err) in your last error-handling middleware to send it to Express error handler.

Good job, you have learned a lot. You have set up an error-handling middleware that funnels the caught errors into your error handler. Funneling both uncaught and caught errors, you are almost ready to handle them. One last thing you should do is create a custom error class. This will help to determine the severity of an error and what the HTTP response status code should be.

Creating Custom Error Class

You can use a custom error class to differentiate errors from one another. You might want to add data to an error or handle it differently than other types of errors.

One improvement you can do is attaching an HTTP response status code to your errors.

You can use an enum to map status codes to a human readable name. This way you don’t have to remember the numbers.

// exceptions/AppError.ts

export enum HttpCode {
  OK = 200,
  NO_CONTENT = 204,
  BAD_REQUEST = 400,
  UNAUTHORIZED = 401,
  NOT_FOUND = 404,
  INTERNAL_SERVER_ERROR = 500,
}

These are some of the most common status codes. Go ahead and add any other ones your application uses. You can use this MDN article as a reference.

Another improvement is separating critical application errors from those that are expected. For example, validation errors caused by user input are fine. Errors thrown from mistakes made by you, the developer, are bad.

Now go and create your own AppError class that extends the default Error class.

// exceptions/AppError.ts

export enum HttpCode { /*...*/ }

interface AppErrorArgs {
  name?: string;
  httpCode: HttpCode;
  description: string;
  isOperational?: boolean;
}

export class AppError extends Error {
  public readonly name: string;
  public readonly httpCode: HttpCode;
  public readonly isOperational: boolean = true;

  constructor(args: AppErrorArgs) {
    super(args.description);

    Object.setPrototypeOf(this, new.target.prototype);

    this.name = args.name || 'Error';
    this.httpCode = args.httpCode;

    if (args.isOperational !== undefined) {
      this.isOperational = args.isOperational;
    }

    Error.captureStackTrace(this);
  }
}

When you use super in a child class, you call the constructor of parent class. In this case, calling super will trigger Error class constructor, which sets the error’s message property to contain your description.

The required httpCode and description is what your application will return in responses. Optionally, you can give your error a name.

The isOperational property is what determines if this error is a serious mistake. Setting it to true means that the error is normal and the user should receive an explanation what caused it.

Any error that is not operational indicates a problem with your code, which you should investigate and fix.

Using the Custom Error Class

You use your AppError class whenever you want to fail the client’s request. Whether it be because user lacks permissions, their input is invalid, or they are not logged in. Or for any other reason you desire.

import { AppError, HttpCode } from './exceptions/AppError';

router.get('/user/:id', async (req: Request, res: Response) => {
  if (!res.locals.user) {
    throw new AppError({
      httpCode: HttpCode.UNAUTHORIZED,
      description: 'You must be logged in',
    });
  }

  const user = await getUserFromDb();

  if (!user) {
    throw new AppError({
      httpCode: HttpCode.NOT_FOUND,
      description: 'User you are looking for does not exist',
    });
  }

  res.json(user);
});

You are not limited to routes, you can throw AppError anywhere in your code. Remember to set isOperational to false when throwing a critical error.

Your error-handling middleware that you created earlier will catch all errors from your routes. It will then send them to your error handler, which you are now finally going to create.

Creating an Error Handler

Your error handler should distinguish errors that can be trusted. A trusted error doesn’t take much work, you just have to send an error response to the client. On the other hand, an error you can’t trust requires extra steps.

Start by creating an ErrorHandler class that can determine if an error can be trusted.

// exceptions/ErrorHandler.ts

import { Response } from 'express';
import { AppError, HttpCode } from './AppError';

class ErrorHandler {
  private isTrustedError(error: Error): boolean {
    if (error instanceof AppError) {
      return error.isOperational;
    }

    return false;
  }
}

export const errorHandler = new ErrorHandler();

You can’t trust any other error than your custom AppError. On top of that, if you set isOperational to false when throwing an AppError, you can’t trust such error either.

Now you should handle trustworthy errors coming into your error handler separately from the rest. Check if the error is trustworthy and send it to its dedicated function. Otherwise send it to a function for critical errors.

class ErrorHandler {
  private isTrustedError(error: Error): boolean { /* ...  */ }

  public handleError(error: Error | AppError, response?: Response): void {
    if (this.isTrustedError(error) && response) {
      this.handleTrustedError(error as AppError, response);
    } else {
      this.handleCriticalError(error, response);
    }
  }
}

If an error is trustworthy, it is an instance of AppError, so you can pass handleTrustedError an argument of error as AppError. Since trusted errors can only come from your error-handling middleware, your error handler always receives the Response object along with the error. So, pass it to handleTrustedError as well.

In case of untrustworthy errors, you will check if the response is defined. Because these errors can come from outside the request-response cycle. For example, when you handle an uncaught exception.

Handle a trusted error by sending the client a response with the HTTP status code and a message.

class ErrorHandler {
  // ...

  private handleTrustedError(error: AppError, response: Response): void {
    response.status(error.httpCode).json({ message: error.message });
  }
}

This is where you could also pass the name property to json(). Naming errors can be useful if you want to translate the error message on the client.

On the other hand, untrustworthy errors are dangerous, because they can make your application behave unexpectedly. Based on Node.js best practices on error handling, you should crash your application when you catch such error.

class ErrorHandler {
  // ...

  private handleCriticalError(error: Error | AppError, response?: Response): void {
    if (response) {
      response
        .status(HttpCode.INTERNAL_SERVER_ERROR)
        .json({ message: 'Internal server error' });
    }

    console.log('Application encountered a critical error. Exiting');
    process.exit(1);
  }
}

Since untrustworthy errors come from outside the error-handling middleware, you should check if Response is available. If it’s defined, send a generic server error message to the client.

This is where you would benefit from an automatic restart setup. If your application exits with a non-zero code, it should restart by itself, so you can crash on critical errors without much downtime.

I have written a post Graceful Shutdown in Express that explains how to create your own exit handler.

Of course, you can do much more than just exiting your application. You can set up error logging or notify yourself via email or a messaging service. You could also improve the shutdown procedure to be graceful, stopping all HTTP connections before the exit.

Summary

You are now knowledgeable of what goes into error handling in Express. Here’s a recap of the things you’ve learned:

  • You need to use express-async-errors, because Express can’t catch async errors on its own.
  • You can catch unhandled rejections and uncaught exceptions by listening to events with process.on.
  • You can customize error handling by making your own error-handling middleware.
  • You should use custom error class to save more information, such as status codes and trustworthiness of the error.
  • You should treat untrustworthy errors seriously and probably just restart your application.

You can see the code in this demo repository.