Skip to main content

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:

javascript
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:

javascript
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:

  1. We attempt to handle a request for a user by ID
  2. If the ID is invalid (≤ 0), we throw an error
  3. 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:

javascript
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:

  1. It uses async/await to make asynchronous code more readable
  2. Errors from the asynchronous operation are properly caught
  3. 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:

javascript
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:

  1. Removes try-catch boilerplate from route handlers
  2. Automatically forwards errors to Express's error handling middleware
  3. 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:

javascript
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:

bash
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:

javascript
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:

  1. Unified error handling for all routes
  2. Environment-specific error responses
  3. Consistent error logging
  4. 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:

javascript
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:

  1. MongoDB-specific error handling
  2. Custom validation and error classification
  3. How to propagate errors with appropriate HTTP status codes
  4. Practical error handling for a REST API

Best Practices for Try-Catch in Express

  1. Use async/await with error middleware: This creates cleaner code than nested callbacks or promise chains.

  2. Classify your errors: Create custom error classes or add properties to distinguish error types:

javascript
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}

// Usage
if (!user) {
throw new NotFoundError('User not found');
}
  1. Include meaningful error messages: Error messages should help with debugging but not expose sensitive information to users.

  2. Log errors comprehensively: But be careful not to log sensitive data like passwords or tokens.

  3. 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:

  1. Basic try-catch in route handlers
  2. Using try-catch with async/await
  3. Creating wrapper functions to reduce boilerplate
  4. Using the express-async-errors package
  5. 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

  1. Create an Express application with at least three routes that implement the try-catch wrapper pattern.
  2. Implement custom error classes for different types of errors in your application.
  3. Build a simple CRUD API with proper error handling for each operation.
  4. 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! :)