Episode 3 — NodeJS MongoDB Backend Architecture / 3.10 — Input Validation

3.10.d — Error Response Format

A consistent error response format across your API ensures that clients can reliably parse errors, display user-friendly messages, and map validation failures to specific form fields.


< 3.10.c — Zod Validation | Exercise Questions >


1. Why Consistent Error Format Matters

Without a standard format, every endpoint returns errors differently, forcing frontend developers to write custom parsing logic for each API call:

// BAD: Inconsistent error responses across endpoints
// Endpoint A
{ "error": "Invalid email" }

// Endpoint B
{ "message": "Validation failed", "errors": ["bad email", "bad password"] }

// Endpoint C
{ "statusCode": 400, "details": { "email": { "msg": "required" } } }

// Frontend developer has to guess the shape of every error response

A standard format means one error handler works everywhere:

// GOOD: Every endpoint returns the same structure
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Input validation failed",
    "details": [
      { "field": "email", "message": "Must be a valid email address" },
      { "field": "password", "message": "Must be at least 8 characters" }
    ]
  }
}

2. Standard Error Response Structure

// Success response
{
  "success": true,
  "data": { /* ... */ }
}

// Error response
{
  "success": false,
  "error": {
    "code": "ERROR_CODE",         // Machine-readable error code
    "message": "Human message",   // Default human-readable message
    "details": []                 // Optional: field-level errors, extra context
  }
}

Error Code Categories

CodeMeaningHTTP Status
VALIDATION_ERRORInput validation failed400 or 422
AUTHENTICATION_ERRORNot logged in / invalid token401
AUTHORIZATION_ERRORLogged in but not allowed403
NOT_FOUNDResource does not exist404
CONFLICTDuplicate resource409
RATE_LIMIT_EXCEEDEDToo many requests429
INTERNAL_ERRORServer error500

3. Validation Error Array Format

Each validation error should include the field name, the error message, and optionally the rejected value:

// Detailed validation errors
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Input validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "value": "not-an-email"        // Optional: helps debugging
      },
      {
        "field": "password",
        "message": "Must be at least 8 characters",
        "value": "123"                 // NEVER include actual password values
      },
      {
        "field": "age",
        "message": "Must be between 13 and 120",
        "value": -5
      }
    ]
  }
}

Alternative: Object-Based Format (field-keyed)

// Useful when frontend needs to display errors next to specific fields
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Input validation failed",
    "details": {
      "email": ["Must be a valid email address"],
      "password": [
        "Must be at least 8 characters",
        "Must contain an uppercase letter"
      ]
    }
  }
}
FormatProsCons
Array of objectsPreserves order, extensible fieldsNeed to search by field name
Object keyed by fieldDirect field lookup, easy for formsNo guaranteed order

4. HTTP Status Codes for Validation

400 Bad Request vs 422 Unprocessable Entity

StatusMeaningUse When
400Malformed request — server cannot parse itInvalid JSON, wrong Content-Type, missing required headers
422Request is well-formed but semantically invalidValid JSON but data fails business rules
// 400 — The request itself is broken
app.use(express.json());
// Express automatically sends 400 for malformed JSON:
// POST with body: {invalid json}  -> 400

// 422 — The request is valid JSON but data is wrong
app.post('/users', validate(schema), (req, res) => {
  // { "email": "not-valid", "age": -5 }
  // JSON is fine, but values fail validation -> 422
  return res.status(422).json({
    success: false,
    error: { code: 'VALIDATION_ERROR', message: '...', details: [...] }
  });
});

Practical recommendation: Most APIs use 400 for all validation errors. Using 422 is more semantically correct but less common. Choose one and be consistent.


5. Client-Friendly Error Messages

Error messages should be understandable by end users, not just developers:

// BAD: Technical messages
{ "message": "String failed regex /^[a-zA-Z\\s]+$/ validation" }
{ "message": "Expected number, received string" }
{ "message": "MongoServerError: E11000 duplicate key error" }

// GOOD: User-friendly messages
{ "message": "Name can only contain letters and spaces" }
{ "message": "Age must be a number" }
{ "message": "An account with this email already exists" }

Custom Error Message Map

// utils/errorMessages.js
const errorMessages = {
  'email.required': 'Email address is required',
  'email.invalid': 'Please enter a valid email address',
  'email.duplicate': 'An account with this email already exists',
  'password.required': 'Password is required',
  'password.weak': 'Password must be at least 8 characters with a number and uppercase letter',
  'password.mismatch': 'Passwords do not match',
  'name.required': 'Name is required',
  'name.length': 'Name must be between 2 and 50 characters',
  'age.range': 'Age must be between 13 and 120',
};

module.exports = errorMessages;

6. i18n Considerations

For international applications, separate error codes from messages:

// Return error codes — let the client translate
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "code": "INVALID_EMAIL",       // Machine-readable
        "message": "Invalid email",    // Default English fallback
        "params": {}                   // Parameters for interpolation
      },
      {
        "field": "name",
        "code": "STRING_TOO_SHORT",
        "message": "Must be at least 2 characters",
        "params": { "min": 2 }        // Frontend uses this for: "{field} doit contenir au moins {min} caracteres"
      }
    ]
  }
}
// Frontend i18n usage (pseudocode)
const translations = {
  en: {
    INVALID_EMAIL: "Please enter a valid email",
    STRING_TOO_SHORT: "Must be at least {min} characters",
  },
  fr: {
    INVALID_EMAIL: "Veuillez entrer un email valide",
    STRING_TOO_SHORT: "Doit contenir au moins {min} caracteres",
  },
};

function translateError(error, locale) {
  const template = translations[locale][error.code];
  return template.replace(/\{(\w+)\}/g, (_, key) => error.params[key]);
}

7. Mapping Validation Errors to Frontend Forms

The error response format should make it trivial for frontend code to show errors next to the correct form fields:

// Backend: structured error response
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Please fix the errors below",
    "details": {
      "email": ["Email is required"],
      "password": ["Too short", "Needs uppercase letter"],
      "address.city": ["City is required"],
      "tags[0]": ["Tag must be a string"]
    }
  }
}
// Frontend React example — display errors next to fields
function RegistrationForm() {
  const [errors, setErrors] = useState({});

  const handleSubmit = async (formData) => {
    const response = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData),
    });

    const data = await response.json();

    if (!data.success) {
      // Set field-level errors from API response
      setErrors(data.error.details);
      return;
    }
    // Success — redirect or show message
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" />
      {errors.email && <span className="error">{errors.email[0]}</span>}

      <input name="password" type="password" />
      {errors.password && <span className="error">{errors.password[0]}</span>}

      <button type="submit">Register</button>
    </form>
  );
}

8. Complete Validation Middleware with Formatted Errors

express-validator Version

// middleware/validate.js
const { validationResult } = require('express-validator');

class AppError extends Error {
  constructor(statusCode, code, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
  }
}

const validate = (validations) => {
  return async (req, res, next) => {
    await Promise.all(validations.map(v => v.run(req)));

    const result = validationResult(req);
    if (result.isEmpty()) return next();

    // Format errors as field -> messages map
    const fieldErrors = {};
    result.array().forEach(err => {
      const field = err.path;
      if (!fieldErrors[field]) fieldErrors[field] = [];
      fieldErrors[field].push(err.msg);
    });

    return res.status(400).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Input validation failed',
        details: fieldErrors,
      },
    });
  };
};

module.exports = { validate, AppError };

Zod Version

// middleware/zodValidate.js
const zodValidate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);

  if (!result.success) {
    const fieldErrors = {};
    result.error.issues.forEach(issue => {
      const field = issue.path.join('.');
      if (!fieldErrors[field]) fieldErrors[field] = [];
      fieldErrors[field].push(issue.message);
    });

    return res.status(400).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Input validation failed',
        details: fieldErrors,
      },
    });
  }

  req.validatedBody = result.data;
  next();
};

module.exports = zodValidate;

Global Error Handler

// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Log the full error for debugging (not sent to client)
  console.error(`[${new Date().toISOString()}] ${err.stack || err.message}`);

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const details = {};
    Object.keys(err.errors).forEach(key => {
      details[key] = [err.errors[key].message];
    });
    return res.status(400).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Database validation failed',
        details,
      },
    });
  }

  // Mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyPattern)[0];
    return res.status(409).json({
      success: false,
      error: {
        code: 'CONFLICT',
        message: `A record with this ${field} already exists`,
        details: { [field]: ['Already exists'] },
      },
    });
  }

  // Custom AppError
  if (err.statusCode) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
    });
  }

  // Unknown error — do NOT leak internal details to client
  res.status(500).json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    },
  });
};

module.exports = errorHandler;
// app.js — wire it all together
const express = require('express');
const errorHandler = require('./middleware/errorHandler');

const app = express();
app.use(express.json());

// Routes
app.use('/api/users', require('./routes/userRoutes'));
app.use('/api/products', require('./routes/productRoutes'));

// 404 handler
app.use((req, res) => {
  res.status(404).json({
    success: false,
    error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.url} not found` },
  });
});

// Global error handler (must be last middleware)
app.use(errorHandler);

app.listen(3000);

Key Takeaways

  1. One format for all errors{ success, error: { code, message, details } }
  2. Machine-readable codesVALIDATION_ERROR, NOT_FOUND, etc., for programmatic handling
  3. Human-readable messages — clear, non-technical descriptions for end users
  4. Field-level details — map errors to form fields for the frontend
  5. 400 vs 422 — pick one and be consistent; 400 is more common in practice
  6. Never leak internals — stack traces and database errors stay in server logs, not responses
  7. Global error handler — catches Mongoose errors, custom errors, and unknown errors in one place

Explain-It Challenge

Design a complete error handling system for a multi-tenant SaaS API. The system must: (1) return consistent error responses for validation errors, auth errors, rate limits, and server errors, (2) support i18n with error codes and interpolation parameters, (3) map nested object validation errors (like address.city) to frontend form fields, and (4) include a global error handler that converts Mongoose errors, JWT errors, and unknown errors into the standard format. Write out the middleware code and show example error responses for each error type.