Express.js is known for being “fast, unopinionated, and minimalist,” as its tagline states. However, this lack of strictness means that while using promises is a common practice in JavaScript, Express.js doesn’t inherently support promise-based route handlers.
Many tutorials fail to mention this, leading developers to duplicate result-sending and error-handling code for each route, which can lead to technical debt. We can prevent this issue using a technique I’ve employed in applications with hundreds of routes.
Common Express.js Route Structure
Let’s examine a simple Express.js tutorial application with routes for a user model.
In real applications, we’d use a database like MongoDB. However, for simplicity, we’ll simulate data storage. We will, however, maintain a good project structure, which is crucial for project success.
While Yeoman can generate better project skeletons, we’ll create a basic one with express-generator and remove unnecessary parts, resulting in:
| |
We’ve streamlined the files irrelevant to our goal.
Here’s our primary Express.js application file, ./app.js:
| |
This code initializes an Express.js app and adds middleware for JSON, URL encoding, and cookie parsing. We then include a usersRouter for /users. Finally, we define actions for when no route matches and how to manage errors, which we’ll modify later.
The server-starting script is located at /bin/start.js:
| |
Our Express.js promises example has a minimal /package.json:
| |
Let’s implement a standard user router in /routes/users.js:
| |
This router has two routes: / for retrieving all users and /:id for fetching a specific user. It utilizes /services/userService.js, which provides promise-based methods for data retrieval:
| |
We’ve avoided using a database connector or ORM like Mongoose or Sequelize, simulating data fetching with Promise.resolve(...).
Challenges with Express.js Routing
Examining our route handlers reveals that each service call repeats .then(...) and .catch(...) callbacks to send data or errors to the client.
This might not seem significant initially. However, let’s introduce real-world requirements: displaying specific errors while hiding generic 500 errors based on the environment. Imagine the complexity when our example project grows from two routes to a real-world application with 200 routes.
Strategy 1: Utility Functions
One solution is creating separate utility functions for handling resolve and reject, using them throughout our Express.js routes:
| |
This avoids repeating data and error-sending logic. However, we still need to import these handlers in each route and add them to every Express promise in then() and catch().
Strategy 2: Middleware
Another approach is using Express.js best practices around promises. We can move error-sending logic into Express.js error middleware (in app.js) and pass asynchronous errors using the next callback. Our basic error middleware might look like this:
| |
Express.js recognizes this as error middleware due to its four input arguments, leveraging the function object’s .length property indicating expected parameters.
Passing errors through next would resemble:
| |
Even following best practices, we still require JS promises in each route handler, resolving with a handleResponse() function and rejecting by passing the next function. Let’s explore a simpler method.
Strategy 3: Promise-based Middleware
JavaScript’s dynamic nature allows adding fields to objects at runtime. We’ll leverage this to extend Express.js result objects within middleware functions.
Implementing promiseMiddleware()
Let’s create promise middleware for more elegant Express.js routes. Create a new file, /middleware/promise.js:
| |
In app.js, apply this middleware to the Express.js app object and update default error handling:
| |
We retain our error middleware for handling synchronous errors. However, instead of repeating error-sending, it passes synchronous errors to the central handleError() function via Promise.reject() called on res.promise().
This handles synchronous errors like this:
| |
Finally, use res.promise() in /routes/users.js:
| |
Notice the different uses of .promise(): with a function or a promise. When passed a function, it wraps it in a promise.
Where should errors be sent to the client? It’s a matter of code organization. We could do it in error middleware (dealing with errors) or promise middleware (interacting with the response object). I prefer keeping response operations within promise middleware, but it’s up to the developer.
res.promise() Is Technically Optional
While we’ve introduced res.promise(), we’re not obligated to use it. Direct response object manipulation is still possible. Let’s look at redirecting and stream piping.
Special Case 1: Redirects
Let’s redirect users to another URL. Add a getUserProfilePicUrl() function in userService.js:
| |
Use it in the users router with async/await:
| |
We utilize async/await, perform the redirect, and maintain centralized error handling with res.promise().
Special Case 2: Stream Piping
Similar to redirects, piping a stream requires direct response object manipulation.
Let’s add a route to serve a generic picture at the URL we’re redirecting to.
Add profilePic.jpg in a new /assets/img subfolder. (In reality, we’d use cloud storage like AWS S3.)
Pipe this image in response to /img/profilePic/:id requests. Create a new router at /routes/img.js:
| |
Add our new /img router in app.js:
| |
Notice we haven’t used res.promise() in the /img router. This is because piping an already-piped response object with an error behaves differently than mid-stream errors.
Handling stream errors in Express.js requires attention, treating errors differently based on timing. Errors before piping can be managed with res.promise(), while mid-stream errors are handled using the .on('error') handler (beyond this article’s scope).
Extending res.promise()
We have flexibility in implementing res.promise(). promiseMiddleware.js can accept options within res.promise() to define response status codes, content types, or other project-specific needs. Developers can tailor their tools and code organization as needed.
Error Handling in Express.js with Modern Promises
This approach provides more elegant route handlers than our starting point, with centralized result and error processing, even outside res.promise(...). However, we’re not forced to use it and can handle edge cases as desired.
The complete code is available available on GitHub. Developers can add custom logic to handleResponse(), like changing the response status from 200 to 204 if no data exists.
The enhanced error control is even more beneficial. This approach allowed me to implement these production features concisely:
- Consistent error formatting as
{error: {message}} - Sending a generic message if no status is provided or passing along the given message
- Populating the
error.stackfield indev(ortest) environments - Handling database index errors (e.g., duplicate unique-indexed entities) with meaningful user errors
All this Express.js route logic resides in one place, decoupled from services, enhancing maintainability and extensibility. This exemplifies how simple yet elegant solutions significantly improve project structure.