A beginner's journey from duplicated try-catch blocks to centralized error middleware
The Problem {#the-problem}
When I started building Express backends, I was confused about:
- ❓ Where to handle errors - controllers or middleware?
- ❓ How to send proper HTTP status codes (400, 401, 404, 500)?
- ❓ How to avoid repeating
try-catch everywhere?
- ❓ What is error middleware and why do I need it?
Phase 1: Inline Error Responses {#phase-1}
What I Initially Wrote
// ❌ PHASE 1: No error handling for async operations
export const register = async (req, res) => {
const { email, password } = req.body;
if (!email) {
res.status(400).json({ error: "Email required" });
return;
}
if (!password) {
res.status(400).json({ error: "Password required" });
return;
}
// ⚠️ This WILL crash if database is down!
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
};
Problems
- ❌ No error handling for async operations
- ❌ Unhandled promise rejections crash the app
- ❌ Inconsistent error formats
- ❌ Validation mixed with business logic
Lesson Learned: We need try-catch for async operations!
Phase 2: Adding Try-Catch {#phase-2}
The "Fix"
// ✅ Better: Won't crash, but duplicated everywhere
export const register = async (req, res) => {
try {
const { email, password } = req.body;
if (!email) {
res.status(400).json({ error: "Email required" });
return;
}
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
} catch (error) {
console.error(error);
res.status(500).json({ error: "Registration failed" });
}
};
// Repeat this pattern 50+ times... 😫
New Problems
- ❌ Massive code duplication (same try-catch in 50+ functions)
- ❌ Inconsistent error messages
- ❌ All errors become 500, even validation errors
- ❌ Lose context about what actually failed
Lesson Learned: We need to distinguish between error types!
Phase 3: Throwing Errors {#phase-3}
Throw Instead of Return
// 🔄 BETTER: Throw errors, catch handles responses
export const register = async (req, res) => {
try {
if (!email) throw new Error("Email required");
if (!password) throw new Error("Password required");
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
} catch (error) {
console.error(error);
res.status(400).json({ error: error.message });
}
};
Still Have Problems
- ❌ All errors get same status code (400)
- ❌ Catch block repeated everywhere
- ❌ Can't differentiate validation vs auth vs not found errors
Lesson Learned: We need custom error classes with status codes!
Phase 4: Custom Error Classes {#phase-4}
Create Error Classes
// src/utils/errors.ts
class AppError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
class ValidationError extends AppError {
constructor(message: string) {
super(400, message); // Always 400
}
}
class UnauthorizedError extends AppError {
constructor(message: string) {
super(401, message); // Always 401
}
}
class NotFoundError extends AppError {
constructor(message: string) {
super(404, message); // Always 404
}
}
class ConflictError extends AppError {
constructor(message: string) {
super(409, message); // Always 409
}
}
Use in Controllers
// 🔄 BETTER: Different error types with correct status codes
export const register = async (req, res) => {
try {
if (!email) throw new ValidationError("Email required"); // 400
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new ConflictError("Email already registered"); // 409
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
} catch (error) {
console.error(error);
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.message });
} else {
res.status(500).json({ error: "Server error" });
}
}
};
Remaining Problem
- ❌ Still duplicating catch block in every controller
Lesson Learned: We need to centralize error handling!
Phase 5: Error Middleware {#phase-5}
Understanding Middleware
Middleware is a function Express calls in sequence:
Request → Middleware 1 → Middleware 2 → Controller → Response
The Magic: Express identifies error handlers by counting parameters!
// Normal middleware (3 parameters)
app.use((req, res, next) => {
console.log("Normal middleware");
next();
});
// Error middleware (4 parameters) ← The magic!
app.use((err, req, res, next) => {
// ^^^ Extra parameter makes it an error handler!
console.log("Error middleware");
res.status(500).json({ error: err.message });
});
When you call next(error), Express skips all normal middleware and jumps to the first function with 4 parameters!
Create Error Middleware
// src/middlewares/error.middleware.ts
export const errorHandler = (
err: Error, // ← 1st parameter
req: Request, // ← 2nd parameter
res: Response, // ← 3rd parameter
next: NextFunction // ← 4th parameter (makes it error handler!)
): void => {
console.error("Error:", err);
// Check if it's our custom error
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
message: err.message,
});
return;
}
// Unknown error - default to 500
res.status(500).json({
success: false,
message: "Internal server error",
});
};
Register in Server (MUST BE LAST!)
// src/server.ts
const app = express();
// Normal middleware
app.use(express.json());
// Routes
app.use("/api/auth", authRoutes);
// ✅ Error handler MUST be registered LAST!
app.use(errorHandler);
app.listen(4000);
Update Controllers
// ✅ MUCH BETTER: Pass errors to middleware
export const register = async (req, res, next) => {
try {
if (!email) throw new ValidationError("Email required");
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new ConflictError("Email already registered");
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
} catch (error) {
next(error); // ← Pass to middleware!
}
};
Visual Flow
Controller throws error
↓
Catch block: next(error)
↓
Express skips normal middleware
↓
Error middleware runs
↓
Sends response to client
Remaining Problem
- ❌ Still have try-catch in every controller
Lesson Learned: Can we eliminate try-catch entirely?
Phase 6: AsyncHandler - The Final Solution! {#phase-6}
The Problem
// Async function returns a Promise
export const register = async (req, res) => {
throw new Error("Oops"); // Creates rejected Promise
};
// Express doesn't catch Promise rejections automatically!
// Result: Unhandled Promise Rejection 💥
The Solution: AsyncHandler Wrapper
// src/utils/asyncHandler.ts
export const asyncHandler = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
How It Works
Think of it as automatic try-catch:
// Without asyncHandler - manual try-catch
export const register = async (req, res, next) => {
try {
const user = await prisma.user.create({ data: req.body });
res.json({ user });
} catch (error) {
next(error);
}
};
// With asyncHandler - automatic try-catch
export const register = asyncHandler(async (req, res) => {
const user = await prisma.user.create({ data: req.body });
res.json({ user });
// asyncHandler automatically does .catch(next) for us!
});
Final Controllers - Clean & Beautiful!
// ✅ PERFECT: No try-catch needed!
export const register = asyncHandler(async (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email) throw new ValidationError("Email required");
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new ConflictError("Email already registered");
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({
success: true,
data: { user },
});
});
export const login = asyncHandler(async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new UnauthorizedError("Invalid credentials");
res.json({
success: true,
data: { user },
});
});
Common Pitfalls {#pitfalls}
Pitfall 1: Wrong Number of Parameters
// ❌ WRONG: Only 2 parameters
app.use((err, res) => {
res.status(500).json({ error: err.message });
});
// ✅ CORRECT: MUST have 4 parameters
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
Pitfall 2: Error Handler in Wrong Position
// ❌ WRONG: Before routes
app.use(errorHandler);
app.use("/api/auth", authRoutes);
// ✅ CORRECT: After routes
app.use("/api/auth", authRoutes);
app.use(errorHandler);
Pitfall 3: Forgetting next() Parameter
// ❌ WRONG: No next parameter
export const register = async (req, res) => {
try {
// ...
} catch (error) {
// Can't pass to middleware!
}
};
// ✅ CORRECT: Include next
export const register = async (req, res, next) => {
try {
// ...
} catch (error) {
next(error);
}
};
Complete Example {#complete}
Project Structure
src/
├── controllers/
│ └── auth.controller.ts
├── middlewares/
│ └── error.middleware.ts
├── routes/
│ └── auth.route.ts
├── utils/
│ ├── errors.ts
│ └── asyncHandler.ts
└── server.ts
1. Error Classes
// src/utils/errors.ts
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string
) {
super(message);
}
}
export class ValidationError extends AppError {
constructor(message: string) { super(400, message); }
}
export class UnauthorizedError extends AppError {
constructor(message: string = "Unauthorized") { super(401, message); }
}
export class NotFoundError extends AppError {
constructor(message: string = "Not found") { super(404, message); }
}
export class ConflictError extends AppError {
constructor(message: string = "Already exists") { super(409, message); }
}
2. AsyncHandler
// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from "express";
export const asyncHandler = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
3. Error Middleware
// src/middlewares/error.middleware.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/errors.js";
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
): void => {
console.error("Error:", err);
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
message: err.message,
});
return;
}
res.status(500).json({
success: false,
message: "Internal server error",
});
};
4. Controller
// src/controllers/auth.controller.ts
import { Request, Response } from "express";
import { asyncHandler } from "../utils/asyncHandler.js";
import { ValidationError, UnauthorizedError, ConflictError } from "../utils/errors.js";
import { prisma } from "../lib/prisma.js";
export const register = asyncHandler(async (req: Request, res: Response) => {
const { email, password, name } = req.body;
if (!email) throw new ValidationError("Email required");
if (!password) throw new ValidationError("Password required");
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) throw new ConflictError("Email already registered");
const user = await prisma.user.create({
data: { email, password, name },
select: { id: true, email: true, name: true },
});
res.status(201).json({
success: true,
data: { user },
});
});
export const login = asyncHandler(async (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email) throw new ValidationError("Email required");
if (!password) throw new ValidationError("Password required");
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new UnauthorizedError("Invalid credentials");
res.status(200).json({
success: true,
data: { user },
});
});
5. Server Setup
// src/server.ts
import express from "express";
import { authRoutes } from "./routes/index.js";
import { errorHandler } from "./middlewares/error.middleware.js";
const app = express();
const PORT = 4000;
// Middleware
app.use(express.json());
// Routes
app.use("/api/auth", authRoutes);
// 404 Handler
app.use("*", (req, res) => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`,
});
});
// Error Handler (MUST BE LAST)
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});
Summary: The Evolution
The Journey
Phase 1: Inline responses → App crashes
Phase 2: Try-catch everywhere → Duplicated 50+ times
Phase 3: Throw in try → Still duplicated catch
Phase 4: Custom errors → Better, but still duplicated
Phase 5: Error middleware → Centralized handling
Phase 6: AsyncHandler → Perfect! Clean & maintainable
Before vs After
// ❌ BEFORE: 15 lines per controller
export const register = async (req, res) => {
try {
if (!email) {
res.status(400).json({ error: "Email required" });
return;
}
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
} catch (error) {
console.error(error);
res.status(500).json({ error: "Registration failed" });
}
};
// ✅ AFTER: 8 lines per controller
export const register = asyncHandler(async (req, res) => {
if (!email) throw new ValidationError("Email required");
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ user });
});
47% less code, infinitely more maintainable! 🎉
Key Takeaways
✅ Custom error classes provide consistent status codes
✅ Error middleware centralizes error handling logic
✅ AsyncHandler eliminates repetitive try-catch blocks
✅ Clean controllers focus only on business logic
✅ Maintainable codebase with consistent error responses
Happy coding! 🚀