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
| Code | Meaning | HTTP Status |
|---|---|---|
VALIDATION_ERROR | Input validation failed | 400 or 422 |
AUTHENTICATION_ERROR | Not logged in / invalid token | 401 |
AUTHORIZATION_ERROR | Logged in but not allowed | 403 |
NOT_FOUND | Resource does not exist | 404 |
CONFLICT | Duplicate resource | 409 |
RATE_LIMIT_EXCEEDED | Too many requests | 429 |
INTERNAL_ERROR | Server error | 500 |
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"
]
}
}
}
| Format | Pros | Cons |
|---|---|---|
| Array of objects | Preserves order, extensible fields | Need to search by field name |
| Object keyed by field | Direct field lookup, easy for forms | No guaranteed order |
4. HTTP Status Codes for Validation
400 Bad Request vs 422 Unprocessable Entity
| Status | Meaning | Use When |
|---|---|---|
| 400 | Malformed request — server cannot parse it | Invalid JSON, wrong Content-Type, missing required headers |
| 422 | Request is well-formed but semantically invalid | Valid 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
- One format for all errors —
{ success, error: { code, message, details } } - Machine-readable codes —
VALIDATION_ERROR,NOT_FOUND, etc., for programmatic handling - Human-readable messages — clear, non-technical descriptions for end users
- Field-level details — map errors to form fields for the frontend
- 400 vs 422 — pick one and be consistent; 400 is more common in practice
- Never leak internals — stack traces and database errors stay in server logs, not responses
- 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.