Express Try-Catch Patterns
Error handling is a critical aspect of building robust Express.js applications. In this lesson, we'll explore various try-catch patterns that help you manage errors effectively in Express, keeping your application stable and user-friendly.
Introduction to Try-Catch in Express
When building Express applications, things don't always go as planned. Database queries may fail, APIs might be unavailable, or users could supply invalid data. Without proper error handling, these issues can crash your server or leave requests hanging indefinitely.
The try-catch mechanism is JavaScript's primary error-handling pattern, and it plays a crucial role in Express applications:
try {
// Code that might throw an error
} catch (error) {
// Code to handle the error
}
Basic Try-Catch in Express Route Handlers
Let's start with a simple example of how to implement try-catch in an Express route:
const express = require('express');
const app = express();
app.get('/users/:id', (req, res) => {
try {
const userId = req.params.id;
// Simulating an error condition
if (userId <= 0) {
throw new Error('Invalid user ID');
}
// Normal successful response
res.json({ id: userId, name: 'John Doe' });
} catch (error) {
// Error handling
console.error('Error fetching user:', error);
res.status(400).json({ error: error.message });
}
});
In this example:
- We attempt to handle a request for a user by ID
- If the ID is invalid (≤ 0), we throw an error
- The catch block captures the error, logs it, and returns a 400 response to the client
Handling Async Operations with Try-Catch
Most Express applications involve asynchronous operations like database queries or API calls. Here's how to use try-catch with async/await:
const express = require('express');
const app = express();
// Simulated database query function
function findUserById(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error('User not found'));
} else {
resolve({ id, name: 'Jane Doe' });
}
}, 100);
});
}
app.get('/users/:id', async (req, res) => {
try {
const userId = parseInt(req.params.id);
const user = await findUserById(userId);
res.json(user);
} catch (error) {
console.error('Error fetching user:', error);
res.status(404).json({ error: error.message });
}
});
This pattern is crucial because:
- It uses
async/await
to make asynchronous code more readable - Errors from the asynchronous operation are properly caught
- The server responds appropriately instead of crashing
The Problem with Try-Catch in Every Route
While the above examples work, adding try-catch blocks to every route handler can become repetitive and make your code harder to read. Let's explore better patterns.
Try-Catch Wrapper Pattern
To avoid repeating try-catch in every route handler, we can create a wrapper function:
const express = require('express');
const app = express();
// Async handler wrapper function
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next))
.catch(next);
};
}
app.get('/users/:id', asyncHandler(async (req, res) => {
const userId = parseInt(req.params.id);
if (userId <= 0) {
throw new Error('Invalid user ID');
}
// Simulate database query
const user = { id: userId, name: 'Alex Smith' };
res.json(user);
}));
Benefits of this pattern:
- Removes try-catch boilerplate from route handlers
- Automatically forwards errors to Express's error handling middleware
- Makes route handler code cleaner and more focused
Using Express-Async-Errors Package
For an even simpler approach, you can use the express-async-errors
package, which patches Express to handle async errors automatically:
const express = require('express');
// This package modifies Express to handle async errors
require('express-async-errors');
const app = express();
// No try-catch or wrapper needed!
app.get('/users/:id', async (req, res) => {
const userId = parseInt(req.params.id);
if (userId <= 0) {
throw new Error('Invalid user ID');
}
// Simulate database call
const user = { id: userId, name: 'Taylor Swift' };
res.json(user);
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message || 'Internal server error' });
});
Installation:
npm install express-async-errors
Centralized Error Handling
A key benefit of these try-catch patterns is they allow you to implement centralized error handling through middleware:
const express = require('express');
require('express-async-errors');
const app = express();
// Routes
app.get('/users/:id', async (req, res) => {
const userId = parseInt(req.params.id);
if (userId <= 0) {
const error = new Error('Invalid user ID');
error.statusCode = 400; // Add custom property to error
throw error;
}
if (userId > 1000) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json({ id: userId, name: 'John Doe' });
});
// Custom error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
// Use custom status code if available, otherwise 500
const statusCode = err.statusCode || 500;
// Different responses based on environment
if (process.env.NODE_ENV === 'production') {
res.status(statusCode).json({
error: statusCode === 500 ? 'Internal server error' : err.message
});
} else {
// More detailed error in development
res.status(statusCode).json({
error: err.message,
stack: err.stack
});
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
This pattern provides several benefits:
- Unified error handling for all routes
- Environment-specific error responses
- Consistent error logging
- Easy to add error reporting services
Real-World Example: Database Operations with Try-Catch
Here's a practical example showing how to handle MongoDB operations in Express:
const express = require('express');
const mongoose = require('mongoose');
require('express-async-errors');
const app = express();
app.use(express.json());
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/myapp')
.catch(err => console.error('MongoDB connection error:', err));
// User model
const User = mongoose.model('User', {
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
age: Number
});
// Create user route
app.post('/users', async (req, res) => {
const { name, email, age } = req.body;
// Validate input
if (!name || !email) {
const error = new Error('Name and email are required');
error.statusCode = 400;
throw error;
}
// Create user
const user = new User({ name, email, age });
await user.save(); // Mongoose operations can throw
res.status(201).json(user);
});
// Get user route
app.get('/users/:id', async (req, res) => {
const { id } = req.params;
// Check if id is valid
if (!mongoose.Types.ObjectId.isValid(id)) {
const error = new Error('Invalid user ID format');
error.statusCode = 400;
throw error;
}
const user = await User.findById(id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
// Handle mongoose validation errors
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation Error',
details: Object.values(err.errors).map(e => e.message)
});
}
// Handle duplicate key errors
if (err.code === 11000) {
return res.status(400).json({
error: 'Duplicate Error',
details: 'A record with this key already exists'
});
}
// Use custom status code if available, otherwise 500
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: err.message
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
This example demonstrates:
- MongoDB-specific error handling
- Custom validation and error classification
- How to propagate errors with appropriate HTTP status codes
- Practical error handling for a REST API
Best Practices for Try-Catch in Express
-
Use async/await with error middleware: This creates cleaner code than nested callbacks or promise chains.
-
Classify your errors: Create custom error classes or add properties to distinguish error types:
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}
// Usage
if (!user) {
throw new NotFoundError('User not found');
}
-
Include meaningful error messages: Error messages should help with debugging but not expose sensitive information to users.
-
Log errors comprehensively: But be careful not to log sensitive data like passwords or tokens.
-
Handle different environments differently: Show detailed errors in development, but limited information in production.
Summary
In this lesson, we explored various try-catch patterns for Express applications:
- Basic try-catch in route handlers
- Using try-catch with async/await
- Creating wrapper functions to reduce boilerplate
- Using the express-async-errors package
- Implementing centralized error handling middleware
These patterns help you build robust applications that gracefully handle errors rather than crashing or leaving users confused when things go wrong.
Exercises
- Create an Express application with at least three routes that implement the try-catch wrapper pattern.
- Implement custom error classes for different types of errors in your application.
- Build a simple CRUD API with proper error handling for each operation.
- Add environment-specific error responses that show detailed errors in development but generic messages in production.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)