Errors as JSON
Give every endpoint one error body, map internal failures onto it, and keep connection strings and stack traces out of responses.
throw new ApiError(404, 'not_found', 'User does not exist');Every endpoint should fail in the same shape, so a client writes one piece of code to read errors. Pick the shape once: a stable error code string, a human message, optional details, and the request id you logged this request under.
Expected versus unexpected
Some failures you raise on purpose: a missing user, a duplicate email, a bad token. Give those a class carrying the status code and the error code, and throw it from anywhere in the handler. Everything else is a bug or an outage, and all of it becomes one 500 with a generic message.
That split is the whole point. A database connection error contains a host and a port. A stack trace contains your file paths. Log those; send the client the request id and nothing more. A code like not_found stays stable even when you reword the message, so clients can branch on it.
Keeping the cause
new Error('loading user failed', { cause: err }) attaches the original error as err.cause. You can add context at each layer and still log the root failure. The catch binding is optional when you do not need it, and these two lines are the same:
try { JSON.parse(text); } catch (err) { return null; }
try { JSON.parse(text); } catch { return null; }
Check res.headersSent before replying from a catch block. If the handler already began the response, you cannot send a status again.
Example
When to use it
- Give clients one error body they can parse across every endpoint.
- Turn a duplicate-email database error into a 409 instead of a 500.
- Keep a database host and port out of a production response.
- Return a request id with every failure so a user can quote it in a support ticket.
More examples
A wrapper that turns every throw into JSON
Without the wrapper, every handler needs its own try/catch and they drift apart. With it, a handler says what went wrong and nothing about how it is reported. The `await` inside the try is required: returning the promise instead would let a rejection escape to the process, because the try block has already finished by then.
// Wrap a handler once; afterwards handlers just throw.
function withErrors(handler) {
return async (req, res) => {
try {
await handler(req, res);
} catch (err) {
const { status, body } = toResponse(err, req.id);
if (res.headersSent) {
// The response already started; all that is left is to stop it.
return res.end();
}
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
};
}
const getUser = withErrors(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new ApiError(404, 'not_found', 'User does not exist');
sendJson(res, 200, user);
});Mapping database errors to status codes
Only the failures you have decided on become client-visible statuses; a unique violation is a 409 because the client can fix it by choosing another email. Everything else keeps the driver's error as `cause` and falls through to the generic 500, so a changed table name does not leak your schema into a response. Doing this mapping in the data layer keeps SQLSTATE codes out of the handlers.
// PostgreSQL sends a five-character SQLSTATE code on err.code (node-postgres).
const SQLSTATE = {
'23505': () => new ApiError(409, 'conflict', 'That value is already taken'),
'23503': () => new ApiError(422, 'invalid_reference', 'Referenced record does not exist'),
};
async function createUser(input) {
try {
const { rows } = await pool.query(
'INSERT INTO users (email) VALUES ($1) RETURNING id, email',
[input.email],
);
return rows[0];
} catch (err) {
const map = SQLSTATE[err.code];
if (map) throw map();
// Unknown database failure: keep the original as the cause and let it become a 500.
throw new Error('creating user failed', { cause: err });
}
}The same shape as Express error middleware
If you later adopt a framework, the mapping function moves across unchanged: only the plumbing differs. The parameter count is load-bearing, and a three-parameter function here silently becomes ordinary middleware that never sees errors. `res.status(status).json(body)` is the shorthand for the writeHead and end pair.
// Registered last, after all routes. Four parameters is what marks it as
// an error handler in Express; three would make it a normal middleware.
app.use((err, req, res, next) => {
const { status, body } = toResponse(err, req.id);
if (res.headersSent) return next(err);
res.status(status).json(body);
});
// A route then throws exactly as before.
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new ApiError(404, 'not_found', 'User does not exist');
res.json(user);
});
Discussion