Express Error Handling: Because Things Will Go Wrong
The Evolution of Error Handling
Let's start with a confession: my first attempt at error handling in Express was... well, let's just show it:
if (error) {
console.log(error);
return res.status(500).json({ error: "Something went wrong" });
}
Yes, really. We've all been there. Then it evolved into something slightly more structured, but still not perfect:
try {
// Do the thing
} catch (error) {
console.error("Error:", error);
return res.status(500).json({
message: "Internal server error",
});
}
This approach had several problems:
- No error typing - every error was treated the same
- Inconsistent error responses across endpoints
- Duplicate try-catch blocks everywhere
- No proper logging (console.log doesn't count!)
- No way to handle async errors properly
- No context about what actually went wrong
Enter express-async-errors: The First Step
Before we dive into custom error types and middleware, we need to talk about express-async-errors
. This tiny but powerful package is crucial for proper Express error handling:
import "express-async-errors";
Why is this important? By default, Express can't handle errors in async functions - they just disappear into the ether. This package ensures that async errors are properly caught and passed to your error handler. It's like giving Express a safety net for all those Promise rejections.
Without it, you'd need to wrap every async route handler in try-catch blocks or use .catch(next)
. With it, you can write clean, async code and let errors bubble up naturally:
// Without express-async-errors - error is lost!
app.get("/risky", async (req, res) => {
const data = await riskyOperation(); // If this fails, Express never knows
res.json(data);
});
// Without express-async-errors - handled but verbose
app.get("/risky", async (req, res, next) => {
try {
const data = await riskyOperation();
res.json(data);
} catch (error) {
next(error);
}
});
// With express-async-errors - clean and safe
app.get("/risky", async (req, res) => {
const data = await riskyOperation(); // Errors properly propagate to handler
res.json(data);
});
Custom Error Types: Making Sense of Chaos
First, I needed to define what errors could actually occur. This meant creating a proper type system for errors:
export type ErrorCode =
| "AUTHENTICATION_ERROR"
| "DATABASE_CONNECTION_ERROR"
| "DESTINATION_GENERATION_FAILED"
| "TRIP_GENERATION_FAILED"
| "PARSING_ERROR"
| "AI_SERVICE_ERROR"
| "VALIDATION_ERROR"
| "DB_QUERY_FAILED"
| "DB_NOT_FOUND";
export interface ServiceError {
message: string;
status: number;
code: ErrorCode;
details?: unknown;
}
Then I created a suite of error creators - functions that would generate consistently structured errors:
export const createServiceError = (
message: string,
status: number,
code: ErrorCode,
details?: unknown,
): ServiceError => ({
message,
status,
code,
details,
});
export const createValidationError = (
message: string = "Validation failed",
details?: unknown,
): ServiceError =>
createServiceError(message, 422, "VALIDATION_ERROR", details);
export const createDBNotFoundError = (
message: string = "Database record not found",
details?: unknown,
): ServiceError => createDatabaseError(message, 404, "DB_NOT_FOUND", details);
Type Guards: Making TypeScript Happy
To safely handle these errors, I needed type guards:
export const isServiceError = (error: unknown): error is ServiceError => {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
"status" in error &&
"message" in error
);
};
export const isAIServiceError = (error: unknown): error is ServiceError => {
return isServiceError(error) && error.code === "AI_SERVICE_ERROR";
};
The Global Error Handler
The real magic happens in the global error middleware. Instead of handling errors in each controller, we can let them bubble up to a single handler:
export const errorHandler: ErrorRequestHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction,
) => {
// Log all errors with request context
logger.error({
error: {
message: err.message,
stack: err.stack,
name: err.name,
},
request: {
method: req.method,
url: req.url,
headers: req.headers,
},
});
// Handle known ServiceErrors
if (isServiceError(err)) {
return res.status(err.status).json({
error: err.message,
code: err.code,
details: process.env.NODE_ENV !== "production" ? err.details : undefined,
});
}
// Handle unknown errors
res.status(500).json({
error:
process.env.NODE_ENV === "production"
? "Internal Server Error"
: err.message,
code: "DB_QUERY_FAILED",
details: process.env.NODE_ENV !== "production" ? err.stack : undefined,
});
return next(err);
};
Before and After: The Beauty of Clean Error Handling
Let's look at how this improved my controller code. Here's the before:
export const handleDeleteDestination = async (
req: Request,
res: Response,
): Promise<Response> => {
const { destinationId } = req.params;
const destinationIdNumber = Number(destinationId);
if (isNaN(destinationIdNumber)) {
const errorMessage = "Invalid destination ID";
logger.warn({ destinationId }, errorMessage);
return res.status(400).json({ error: errorMessage });
}
try {
const deletedDestination = await deleteDestinationById(destinationIdNumber);
logger.info(
{ destinationId: destinationIdNumber },
"Destination deleted successfully",
);
return res.status(200).json({
message: "Destination deleted successfully:",
deletedDestination,
});
} catch (error) {
if (isServiceError(error) && error.code === "DB_NOT_FOUND") {
logger.warn(
{ error, destinationId: destinationIdNumber },
"Destination not found",
);
return res.status(404).json({ error: error.message });
}
logger.error(
{ error, destinationId: destinationIdNumber },
"Error deleting destination",
);
return res.status(500).json({ error: "Internal server error" });
}
};
And here's the after:
export const handleDeleteDestination = async (
req: Request,
res: Response,
): Promise<Response> => {
const { destinationId } = req.params;
const destinationIdNumber = Number(destinationId);
if (isNaN(destinationIdNumber)) {
throw createValidationError("Invalid destination ID");
}
const deletedDestination = await deleteDestinationById(destinationIdNumber);
logger.info(
{ destinationId: destinationIdNumber },
"Destination deleted successfully",
);
return res.status(200).json({
message: "Destination deleted successfully:",
deletedDestination,
});
};
The difference is striking. The new version:
- Is significantly shorter
- Focuses on the happy path
- Has consistent error handling
- Maintains type safety
- Still logs everything we need
Putting It All Together
To make this work, I needed a few pieces:
- Install
express-async-errors
to handle async errors:
import "express-async-errors";
- Register the error handler after all routes:
app.use(errorHandler);
- Update my services to use the error creators:
export const deleteDestinationById = async (id: number) => {
const destination = await db
.delete(destinations)
.where(eq(destinations.id, id))
.returning();
if (!destination.length) {
throw createDBNotFoundError("Destination not found");
}
return destination[0];
};
What I Actually Learned
- Centralized error handling is worth the initial setup time
- Type safety in error handling prevents entire categories of bugs
- Clean error handling makes code more readable and maintainable
- Consistent error structures make API responses more predictable
- Environment-aware error details improve security
- Good error handling and good logging go hand in hand
Looking Forward
While this system works well, there's always room for improvement:
- Adding error boundaries for the React frontend
- Implementing retry logic for transient failures
- Adding error tracking (probably Sentry)
- Building better error reporting dashboards
But for now, I have a clean, type-safe error handling system that makes debugging easier and keeps my controllers focused on their primary responsibilities.
Next up: Refactoring the entire backend test suite using supertest!