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);
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
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);
};
Use it in your route:
import asyncHandler from "express-async-handler";
app.get("/users/:id", asyncHandler(getUserById));
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!`
});
});
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
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)
);
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');
});
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)
);
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'
});
});
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);
};
}
- 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));
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!
Top comments (0)