Returning error.message from an API looks convenient. It also leaks database details, produces unstable client behavior, and gives users messages written for developers.
I separate an error into two views: a stable public contract and its internal cause.
export class AppError extends Error {
constructor(
readonly code: string,
readonly statusCode: number,
readonly publicMessage: string,
options?: { cause?: unknown; operational?: boolean },
) {
super(publicMessage, { cause: options?.cause });
this.name = new.target.name;
}
}
export class ResourceNotFoundError extends AppError {
constructor() {
super("RESOURCE_NOT_FOUND", 404, "The requested item was not found.");
}
}
The client can branch on code, display publicMessage, and treat statusCode using normal HTTP semantics. The server logs the original cause and stack with a correlation ID.
Translate errors at boundaries#
Infrastructure errors should not escape directly:
try {
await collection.insertOne(document);
} catch (error) {
if (isDuplicateKey(error)) {
throw new EmailAlreadyRegisteredError({ cause: error });
}
throw new AppError("INTERNAL_ERROR", 500, "Something went wrong.", {
cause: error,
});
}
This translation keeps MongoDB error codes out of the API contract. A later storage change does not force clients to change.
Do not make every failure a 500#
Validation failures are usually 400, missing authentication is 401, insufficient permission is 403, absence is 404, and conflicts such as an already-used email are often 409. Choose semantics consistently and document stable codes.
Unknown exceptions are different. Log them as unexpected and return one generic 500 response. Do not guess a detailed public message from an error you do not understand.
Errors are part of the API#
Test the error code and status clients depend on. Avoid asserting internal database wording. Include a correlation ID in the response so support can connect a report to the detailed server log.
A useful error contract tells the caller what kind of failure occurred and what it may do next. It tells the operator what actually broke without telling the user anything unsafe.