Skip to main content

Express Error Handling: Because Things Will Go Wrong

ExpressTypeScriptError HandlingBackendMiddlewareWooster

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:

  1. No error typing - every error was treated the same
  2. Inconsistent error responses across endpoints
  3. Duplicate try-catch blocks everywhere
  4. No proper logging (console.log doesn't count!)
  5. No way to handle async errors properly
  6. 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:

  1. Install express-async-errors to handle async errors:
import "express-async-errors";
  1. Register the error handler after all routes:
app.use(errorHandler);
  1. 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

  1. Centralized error handling is worth the initial setup time
  2. Type safety in error handling prevents entire categories of bugs
  3. Clean error handling makes code more readable and maintainable
  4. Consistent error structures make API responses more predictable
  5. Environment-aware error details improve security
  6. Good error handling and good logging go hand in hand

Looking Forward

While this system works well, there's always room for improvement:

  1. Adding error boundaries for the React frontend
  2. Implementing retry logic for transient failures
  3. Adding error tracking (probably Sentry)
  4. 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!