Developers judge an API by its worst day. When input is wrong or a dependency is down, a great API responds with precise, consistent, actionable errors; a poor one returns a 500 with an HTML stack trace. In this lesson we design an error contract, validate input at the boundary, and centralize handling so the whole codebase speaks failure in one voice.
Every error from every endpoint should share a machine-readable shape. A proven structure:
{
"error": {
"code": "validation_failed",
"message": "Request body has 2 invalid fields",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "age", "issue": "must be an integer >= 13" }
],
"requestId": "req_8c1d44"
}
}The code is a stable string clients can branch on (never parse human messages); message is for developers reading logs; details pinpoints every invalid field at once, so a form can highlight all problems in a single round trip; requestId lets support correlate a user report with server logs. There is also a formal standard for this, Problem Details (RFC 9457) with application/problem+json, worth adopting for public APIs.
Validation belongs at the edge of your application, before any business logic runs. Hand-rolled if-chains rot quickly; schema libraries like Zod, Joi, or Yup declare the contract once and derive both validation and (in TypeScript) static types from it.
const { z } = require('zod');
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().gte(13).optional()
}).strict(); // reject unknown fieldsThree habits matter. Validate structure and business rules separately: schema failures are 400, while rule failures like "email already registered" are 409 or 422 discovered deeper in the flow. Reject unknown fields (strict mode) so client typos like "emial" fail loudly instead of silently dropping data. And validate query parameters and path parameters too, not just bodies; ?limit=abc deserves a clean 400.
With a consistent envelope defined, no route should hand-format errors. Create a small error class and let one Express error handler translate everything.
class ApiError extends Error {
constructor(status, code, message, details) {
super(message);
Object.assign(this, { status, code, details });
}
}
// in a route: throw new ApiError(404, 'not_found', 'book not found');The final handler maps ApiError instances to their status and envelope, maps schema failures to 400 with field details, and maps everything unexpected to a generic 500. Two rules for the 500 branch: log the full stack trace with the requestId server-side, and expose nothing internal to the caller. Stack traces, SQL fragments, and library names in error responses are reconnaissance gifts to attackers.
Real services call databases and other APIs, which fail in their own ways. Map dependency timeouts to 502 or 504 rather than generic 500s so operators see at a glance where a failure originated, and consider a retry with backoff for idempotent reads. In Express 4, remember that async route errors must reach next(err); either wrap handlers in a small asyncHandler utility or use Express 5, which forwards rejected promises automatically. An unhandled promise rejection that crashes the process is the least graceful error response of all.
Next up: REST API Security Best Practices, hardening everything you have built.