A handler that knows something went wrong should not know what the response looks like. It throws a typed error carrying a status code, and the middleware at the bottom of the stack decides the rest. Custom Errors builds the custom error class; the Express 24,430 addition is three properties — status, a machine-readable code, and expose, which says whether the message may be shown to the client.
export class AppError extends Error {
constructor(message, { status = 500, code = 'internal_error', details, cause } = {}) {
super(message, { cause });
this.name = new.target.name;
this.status = status;
this.code = code;
this.details = details;
this.expose = status < 500; // 4xx messages are safe to show, 5xx are not
Error.captureStackTrace?.(this, new.target);
}
}
export class ValidationError extends AppError {
constructor(details) {
super('Request validation failed', { status: 422, code: 'validation_failed', details });
}
}new.target.name sets name to the subclass, so logs read ConflictError rather than Error, and captureStackTrace drops the constructor frame so the trace starts where you threw. Pass cause when you re-throw: the driver error behind a 503 stays attached without being exposed. NotFoundError and ConflictError follow the same shape as ValidationError, with a different status and code.
A schema failure is 400 or 422; both are defensible, so pick one and use it everywhere. The rest divide cleanly:
400 — unparseable, such as a malformed JSON body;
401 / 403 — not authenticated (missing token) versus not allowed (wrong role);
404 — the resource does not exist, as in GET /api/books/99;
409 — state conflicts with the request, such as a duplicate ISBN;
422 — parsed, but semantically invalid, such as year: 2100.
The http-errors 1,560 package (2.0.1) is the alternative, already in your tree because Express throws its errors: createError(409, 'Duplicate ISBN') gives you status, statusCode and expose for free. Write your own class when you need an extra field such as code or details — which is most APIs.