Problem Details

Consistent Error Payloads with Problem Details

The body shape in Error Middleware works, but every API invents its own. RFC 9457, Problem Details for HTTP APIs (July 2023, obsoleting RFC 7807), standardizes it: a JSON object served as application/problem+json with five members — type, a URI naming the problem kind; title, a short summary of that kind, stable across occurrences; status, an advisory copy of the HTTP status; detail, an explanation of this occurrence; and instance, a URI identifying the occurrence. Extra members are allowed. Adopting it changes the tail of one function, not your routes.

The error handler of Section 3.7.6, emitting problem+jsonJavaScript
const P = 'https://example.com/problems/';
const PROBLEMS = {
  validation_failed: { type: P + 'validation', title: 'Invalid request body' },
  not_found: { type: P + 'not-found', title: 'Resource not found' },
  internal_error: { type: 'about:blank', title: 'Internal Server Error' }
};
const code = status >= 500 ? 'internal_error' : err.code;
const { type, title } = PROBLEMS[code] ?? PROBLEMS.internal_error;
res.status(status).type('application/problem+json').json({
  type, title, status, instance: req.originalUrl, traceId: req.id,
  detail: status >= 500 ? 'The request could not be completed.' : err.message,
  ...(err.details ? { errors: err.details } : {})
});
Output
{ "type": "https://example.com/problems/validation", "title": "Invalid request body",
  "status": 422, "instance": "/api/books", "traceId": "8b2f",
  "detail": "Request validation failed",
  "errors": [ { "in": "body", "field": "year", "code": "too_big",
                "message": "Too big: expected number to be <=2026" } ] }

title describes the class of problem and detail the instance, which is the distinction most implementations get backwards. Keep title identical for every 422 so a client can switch on it, and put the changing text in detail. type is a stable identifier, not necessarily a live page — clients must not dereference it — but pointing it at real documentation costs nothing. about:blank is correct when the status alone says everything.

The per-field list is an extension member; RFC 9457 defines no standard name, so errors is a convention. Carry your correlation id here too (Correlation IDs): a user who can quote traceId turns an unreproducible bug report into one log query.