Error Handling in Mongoose Operations

A Mongoose-backed Express API has a well-defined set of errors it can produce โ€” ValidationError, CastError, duplicate key errors, and unexpected operational errors. Each has a known shape, a correct HTTP status code, and a message that your React frontend can display usefully. In this lesson you will verify that your global error handler correctly handles every Mongoose error type, write test cases that deliberately trigger each error, and confirm that no scenario returns an unhelpful 500 response when a more specific 400 or 409 is appropriate.

Mongoose Error Types Catalogue

Error err.name / err.code When It Occurs Correct Status
ValidationError err.name === 'ValidationError' required missing, minlength, enum, custom validator fails 400
CastError err.name === 'CastError' Invalid ObjectId format in params or filter 400
Duplicate Key err.code === 11000 Unique index violation (email, slug) 409
DocumentNotFoundError err.name === 'DocumentNotFoundError' Returned when using orFail() on a query that finds nothing 404
MongoNetworkError err.name === 'MongoNetworkError' Database unreachable 503
Unexpected (bug) Any other Programming error โ€” undefined property, wrong type etc. 500
Note: All Mongoose errors propagate through next(err) via your asyncHandler wrapper โ€” you do not need to catch them individually in each controller. The global error handler (Chapter 7, Lesson 5) already handles ValidationError, CastError, and duplicate key errors. Your job in this lesson is to verify that every scenario actually reaches the error handler with the right error type, and that the handler returns the expected status code and message.
Tip: Use Postman’s Tests tab to write automated assertions for every error scenario. For each error type, create a dedicated Postman request that deliberately triggers the error and asserts: (1) the HTTP status code is correct, (2) success is false, and (3) the message is descriptive enough for a React form to use. Run the whole collection before every deployment to catch regressions.
Warning: Never expose Mongoose error details directly to the client in production. A raw ValidationError object contains the full schema path, value, and stack trace. Your global error handler should extract only the message string for each error and discard everything else before sending the response. In development mode, you can include the stack trace โ€” but gate it strictly on process.env.NODE_ENV === 'development'.

The Complete errorHandler โ€” Handling All Mongoose Errors

// server/src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Always log the full error server-side
  if (process.env.NODE_ENV === 'development') {
    console.error(`[ERROR] ${err.name}: ${err.message}`);
    console.error(err.stack);
  } else {
    console.error(`[ERROR] ${err.name}: ${err.message}`);
  }

  let statusCode = err.statusCode || 500;
  let message    = err.message    || 'Internal Server Error';
  let isOperational = !!err.isOperational;

  // โ”€โ”€ Mongoose: invalid ObjectId โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (err.name === 'CastError' && err.kind === 'ObjectId') {
    statusCode    = 400;
    message       = `Invalid ID format: '${err.value}'`;
    isOperational = true;
  }

  // โ”€โ”€ Mongoose: schema validation failed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (err.name === 'ValidationError') {
    statusCode    = 400;
    const errors  = Object.values(err.errors).map(e => ({
      field: e.path, message: e.message,
    }));
    message       = errors.map(e => e.message).join('; ');
    isOperational = true;
    return res.status(statusCode).json({ success: false, message, errors });
  }

  // โ”€โ”€ MongoDB: duplicate key โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (err.code === 11000) {
    const field   = Object.keys(err.keyValue || {})[0] || 'field';
    const value   = err.keyValue?.[field];
    statusCode    = 409;
    message       = `${field.charAt(0).toUpperCase() + field.slice(1)} '${value}' already exists`;
    isOperational = true;
  }

  // โ”€โ”€ JWT: invalid signature โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (err.name === 'JsonWebTokenError') {
    statusCode    = 401;
    message       = 'Invalid token โ€” please log in again';
    isOperational = true;
  }

  // โ”€โ”€ JWT: expired โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (err.name === 'TokenExpiredError') {
    statusCode    = 401;
    message       = 'Your session has expired โ€” please log in again';
    isOperational = true;
  }

  // โ”€โ”€ Payload too large โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (err.type === 'entity.too.large') {
    statusCode    = 413;
    message       = 'Request body too large';
    isOperational = true;
  }

  // In production: hide internal details for non-operational errors
  if (process.env.NODE_ENV === 'production' && !isOperational) {
    message = 'An unexpected error occurred';
  }

  res.status(statusCode).json({
    success: false,
    message,
    ...(process.env.NODE_ENV === 'development' && {
      errorName: err.name,
      stack:     err.stack,
    }),
  });
};

module.exports = errorHandler;

Test Cases โ€” Triggering Each Error Type

โ”€โ”€ ValidationError โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
POST /api/posts  Authorization: Bearer <valid_token>
Body: { "body": "Content without a title" }

Expected: 400
{ "success": false, "message": "Post title is required",
  "errors": [{ "field": "title", "message": "Post title is required" }] }

โ”€โ”€ CastError (invalid ObjectId) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
GET /api/posts/not-a-valid-id

Expected: 400
{ "success": false, "message": "Invalid ID format: 'not-a-valid-id'" }

โ”€โ”€ Duplicate Key Error (email already exists) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
POST /api/auth/register
Body: { "name": "Jane", "email": "existing@email.com", "password": "Test@1234" }

Expected: 409
{ "success": false, "message": "Email 'existing@email.com' already exists" }

โ”€โ”€ Not Found (AppError 404) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
GET /api/posts/64a1f2b3c8e4d5f6a7b8c9d0  (valid ObjectId, no matching document)

Expected: 404
{ "success": false, "message": "Post not found" }

โ”€โ”€ Unauthorized (no token) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
POST /api/posts  (no Authorization header)

Expected: 401
{ "success": false, "message": "No token provided โ€” please log in" }

โ”€โ”€ Forbidden (valid token, not the owner) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
DELETE /api/posts/:id  (authenticated as user who does not own the post)

Expected: 403
{ "success": false, "message": "Not authorised to delete this post" }

Using orFail() for Cleaner Not-Found Handling

// Instead of:
const post = await Post.findById(id);
if (!post) throw new AppError('Post not found', 404);

// You can use .orFail():
const post = await Post.findById(id)
  .orFail(new AppError('Post not found', 404));
// If document is not found โ†’ throws the provided error automatically
// asyncHandler catches it โ†’ passed to errorHandler โ†’ 404 response โœ“

Common Mistakes

Mistake 1 โ€” Returning 500 for a known Mongoose error type

โŒ Wrong โ€” errorHandler does not handle CastError specifically:

// errorHandler without CastError handling
const errorHandler = (err, req, res, next) => {
  res.status(500).json({ message: err.message });
  // GET /api/posts/invalid-id โ†’ 500 "Cast to ObjectId failed..."
  // Should be 400 "Invalid ID format"
};

โœ… Correct โ€” handle each Mongoose error type explicitly in the global error handler.

Mistake 2 โ€” Sending raw Mongoose ValidationError to the client

โŒ Wrong โ€” the raw err object exposed to React:

res.status(400).json({ error: err });
// Sends the entire Mongoose error object including internal paths,
// validators array, stack trace, etc. โ€” too much information to the client

โœ… Correct โ€” extract and format only the user-facing messages:

const errors = Object.values(err.errors).map(e => ({ field: e.path, message: e.message }));
res.status(400).json({ success: false, message: '...', errors }); // โœ“ clean format

Mistake 3 โ€” Not testing error paths in Postman

โŒ Wrong โ€” only testing the happy path and discovering error handling bugs in production.

โœ… Correct โ€” for every route, create at minimum these test scenarios in Postman: valid input, missing required fields, invalid ObjectId, no token, wrong token, not owner. Run the collection before every deployment.

Quick Reference โ€” Error Types and Status Codes

Mongoose Error Detection HTTP Status
ValidationError err.name === 'ValidationError' 400
CastError (bad ObjectId) err.name === 'CastError' 400
Duplicate key err.code === 11000 409
JsonWebTokenError err.name === 'JsonWebTokenError' 401
TokenExpiredError err.name === 'TokenExpiredError' 401
AppError (custom) err.isOperational === true err.statusCode
Unexpected (bug) All others 500

🧠 Test Yourself

A client sends GET /api/posts/abc. Mongoose tries to query with _id: 'abc', fails with a CastError, and asyncHandler passes it to your global errorHandler. The handler currently only checks for err.isOperational. What response does the client receive?