Stop Wrapping Controllers in Try-Catch: The express-async-handler Solution

Arsalan Ahmed Yaldram - Sep 30 - - Dev Community

Introduction

As a JavaScript developer, you've probably seen Express controllers wrapped in try-catch blocks to handle async errors. While this works, it can make your code repetitive and cluttered. Enter express-async-handler: a simple tool that eliminates the need for try-catch in every controller. It's designed to handle async errors keeping your code clean and easy to read. In this post, we'll explore how express-async-handler can simplify your Express code and make error handling a breeze.

The Current Scene: Try-Catch Overload

Picture this in your Express app:

export const getUserById = async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ message: "User not found" });
    res.status(200).json(user);
  } catch (error) {
    res.status(500).json({ message: "Server error", error: error.message });
  }
};

// Using the controller
app.get("/users/:id", getUserById);
Enter fullscreen mode Exit fullscreen mode

See that try-catch wrapping everything? It's like a safety blanket for your code. It works, sure, but imagine doing this for every single controller.

Enter express-async-handler: Your Code's New Best Friend

Install it:

npm install express-async-handler
Enter fullscreen mode Exit fullscreen mode

Refactor your controller by removing try-catch:

export const getUserById = async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) return res.status(404).json({ message: "User not found" });
  return res.status(200).json(user);
};
Enter fullscreen mode Exit fullscreen mode

Use it in your route:

import asyncHandler from "express-async-handler";

app.get("/users/:id", asyncHandler(getUserById));
Enter fullscreen mode Exit fullscreen mode

Now that we've streamlined our controllers, let's add a global error handler to catch any errors:

import express from 'express';

const app = express();

// Global Error Handler
app.use((error, req, res, next) => {
  console.log.(`Oops! ${error.stack}`);

  res.status(500).json({
    status: false,
    statusCode: 500,
    message: `Something went wrong on our end. We're on it!`
  });
});
Enter fullscreen mode Exit fullscreen mode

That's it! No more try-catch clutter. If an error pops up, express-async-handler catches it and passes it to Express's global error handler. With express-async-handler, you're writing less boilerplate and focusing more on what your code actually does.

Middleware: Your Request's Pit Stop

Middleware is a function that runs between receiving a request and sending a response. Think of it as an assistant that can:

  • Check if a user is logged in
  • Validate data
  • Log request details
  • Modify the request or response

Here’s a basic middleware example:

const logRequest = (req, res, next) => {
  console.log(`Received a ${req.method} request to ${req.url}`);
  next(); // Don't forget this! It passes control to the next middleware
};

app.use(logRequest); // Use this middleware globally for all routes
Enter fullscreen mode Exit fullscreen mode

In this case, logRequest logs all incoming requests and is used globally, meaning it will run on every route in your app.

How Middleware Works

Middleware functions take three arguments: req (request), res (response), and next. The next() function is crucial—it passes control to the next middleware in the stack. Without it, the request-response cycle would stop and the request would hang.

Middleware can be applied globally, as in the logRequest example, or individually to specific routes. For instance:

router.post(
  '/',
  validateRequestBody(todosSchema), // Middleware for request validation
  asyncHandler(todosController.createTodo)
);
Enter fullscreen mode Exit fullscreen mode

Here, validateRequestBody checks whether the request body matches the todosSchema before the request reaches the controller. In a similar fashion, you can add custom middlewares for tasks like validating data, authenticating users, or logging specific details on individual routes or across your entire app. Our controllers function as middleware too.

Global Error Handler: Your App's Safety Net

A global error handler in Express is special middleware that catches errors from anywhere in your app. Here’s a simple example:

app.use((error, req, res, next) => {
  console.error(error.stack);
  res.status(500).send('Oops! Something went wrong');
});
Enter fullscreen mode Exit fullscreen mode

In this basic setup, any unhandled errors will be logged, and the user will get a general error message. But in a real-world app, you often need more specific error handling based on the error type.

The great thing about this approach is that it works hand-in-hand with express-async-middleware and other custom middlewares like validateRequestBody that we discussed earlier:

router.post(
  '/',
  validateRequestBody(todosSchema),
  asyncHandler(todosController.createTodo)
);
Enter fullscreen mode Exit fullscreen mode
app.use((error, req, res, next) => {
  logger.fatal(`Error: ${error.stack}`);

  if (error instanceof ValidationError) {
    return res.status(400).json({
      message: 'Validation failed',
      errors: error.validationErrors
    });
  }

  if (error.code === 'FOREIGN_KEY_CONSTRAINT') {
    return res.status(500).json({
      message: 'Database error: Constraint violation. Please ensure that related data exists before proceeding.'
    });
  }

  return res.status(500).json({
    message: 'Something unusual happened'
  });
});
Enter fullscreen mode Exit fullscreen mode

If validateRequestBody throws a validation error, it’s automatically caught by the global error handler. Similarly, database-related errors can also be managed here.

Using this centralized error handling along with express-async-handler and other custom middlewares means you no longer need repetitive try-catch blocks in each route. Instead, you have a streamlined, clean solution that handles errors in a single place, making your app more maintainable and easier to debug.

Cracking Open express-async-handler

If you check the express-async-handler codebase, it's just 8 lines of code—yes, only 8! It’s a simple curried function—a function that returns another function. Below is a simplified version, let's peek under the hood:

export function asyncHandler(controllerFunction) {
  return function asyncMiddleware(req, res, next) {
    const controllerPromise = controllerFunction(req, res, next);
    return Promise.resolve(controllerPromise).catch(next);
  };
}
Enter fullscreen mode Exit fullscreen mode
  • The Wrapper: asyncHandler is the main function that creates a middleware wrapper. It takes a controllerFunction as its argument, which is the function that handles the route logic.
  • The Return: The asyncHandler function returns another function (asyncMiddleware). This returned function is the actual middleware used in Express.
  • The Magic: When this function runs, it:
    • Calls your controller function
    • Wraps the result in a Promise (in case it's not already one)
    • Attaches a .catch() handler to the promise. If the promise is rejected (an error occurs), the error is passed to the next function, which forwards it to the global error handler.

In plain English: express-async-handler takes your controller, runs it, and makes sure any errors are caught and sent to the global error handler. This tiny piece of code saves you from writing try-catch blocks everywhere. It's a small tool that makes a big difference in keeping your code clean and error-free.

When It’s Okay to Use try-catch in Controllers

There’s nothing wrong with using a try-catch block inside a controller when you need to handle specific, localized errors. The key takeaway is balance. For global or repetitive errors, using express-async-handler and a global error handler middleware is the best approach. However, for unique situations that are controller-specific, a try-catch makes sense and is perfectly valid:

export const getUserById = async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ message: "User not found" });
    return res.status(200).json(user);
  } catch (error) {
    // Handle specific route errors, e.g., database constraints
    if (error.code === "E203") {
      return res.status(500).json({ message: "Server error", error: error.message });
    }

    // Let the global error handler take care of any other errors
    throw error;
  }
};

app.get("/users/:id", asyncHandler(getUserById));
Enter fullscreen mode Exit fullscreen mode

Wrapping Up: Cleaner, Safer Express

We've explored how to simplify error handling in Express, and here's the takeaway:

  • Say Goodbye to Try-Catch Overload: express-async-handler eliminates repetitive try-catch blocks.
  • Cleaner Code: Your controllers are now focused and streamlined.
  • Global Error Handling: Catch errors with a centralized error handler.
  • Middleware Magic: Middleware transforms your app's flow and keeps things tidy.
  • Simple Yet Powerful: A few clever lines of code make a big difference.

If you’re looking to build Express apps from scratch, check out this complete TypeScript starter that follows clean coding principles and includes end-to-end testing: express-typescript-starter. I’ve also written a 7-part series on it—take a look here. Until next time Happy coding!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player