One error shape, everywhere
When something goes wrong, a lazy API returns a bare string, a different JSON shape per endpoint, or worse, a 200 with "error" hidden inside. Clients then need special handling for every case.
Great APIs pick one error shape and use it for every failure:
{
"error": {
"code": "user_not_found",
"message": "No user with id 42",
"details": []
}
}code: a stable, machine-readable string. Client code branches on this, never on the message.message: a human-readable explanation for developers and logs.details: optional specifics, like which fields failed validation.
The status code (lesson 3-3) says what family of problem, the body says exactly which one.
An error factory
Every failure in the API goes through one function, so the shape can never drift between endpoints.
function buildError(status, code, message) { return { status: status, body: { error: { code: code, message: message } }, }; } const notFound = buildError(404, "user_not_found", "No user with id 42"); console.log(notFound.status); console.log(JSON.stringify(notFound.body));
Output
404 {"error":{"code":"user_not_found","message":"No user with id 42"}}
One factory function is the whole trick, since nobody hand-builds error JSON and so nobody gets it wrong. The nesting under an error key is decided once, in one place, and every endpoint inherits it.
Returning the status alongside the body keeps the two together, which matters because they have to agree. A 404 status with a body saying invalid_input is a contradiction, and pairing them in one return value makes the mismatch hard to write.
The error wrapper also makes responses self-describing for the client. A body with an error key is a failure and a body without one is data, so client code can tell the two apart without inspecting the status, which is useful in logs and test fixtures.
Note that a real factory usually adds optional details and sometimes a request id for correlating with server logs. Adding a field to one function updates every error in the API at once, which is the payoff for having the function at all.
Why clients branch on the code, not the message
Because messages are for humans and may be reworded or translated, while codes are a stable contract.
The moment someone fixes a typo in "No user wtih id", every client matching on the message breaks. That is a change nobody would think of as breaking, made by someone who has never seen the client code.
Codes are part of your API contract, meaning documented, stable, and safe to branch on. Messages stay free to improve, localize, or add detail, and that freedom only exists because nothing depends on their exact text.
The naming convention matters a little too. Codes like user_not_found are lowercase with underscores by convention, they describe the situation rather than the fix, and they stay stable even when the wording of the message changes completely.
| Field | Audience | May change |
|---|---|---|
| status | machines, caches, monitors | no |
code | client code | no |
message | developers, logs | yes |
details | forms and validation UI | additively |
Note the practical consequence for the server side. Adding a new error case means inventing a new code and documenting it, so the set of codes for an endpoint is part of its interface just as much as its response fields.
Validation that reports every problem
validateSignup collects one details entry per broken field, so a client can highlight all of them at once.
function validateSignup(body) { const details = []; if (!body.email || !body.email.includes("@")) { details.push({ field: "email", message: "valid email required" }); } if (!body.password || body.password.length < 8) { details.push({ field: "password", message: "at least 8 characters" }); } if (details.length === 0) { return { status: 200, body: { ok: true } }; } return { status: 400, body: { error: { code: "invalid_input", details: details } }, }; } console.log(JSON.stringify(validateSignup({ email: "ada@example.com", password: "verysecret" }))); console.log(JSON.stringify(validateSignup({ email: "nope", password: "short" })));
Output
{"status":200,"body":{"ok":true}}
{"status":400,"body":{"error":{"code":"invalid_input","details":[{"field":"email","message":"valid email required"},{"field":"password","message":"at least 8 characters"}]}}}Each check is two conditions joined by ||, so !body.password || body.password.length < 8 handles both a missing field and a short one. The presence check has to come first, because reading .length off undefined throws.
details.length === 0 means the input passed every check, which is a cleaner signal than a separate valid flag. The array is both the answer and the evidence, so there is nothing to keep in sync.
Collecting rather than returning on the first failure is the design decision worth defending. A client that gets one error per attempt makes the user submit the form repeatedly, and the full list lets every field be marked at once.
Each entry names its field, which is what allows the client to attach the message to the right input. A flat array of strings would carry the same information for a human and leave the UI guessing which box to outline.
The 400 body nests as { error: { code: "invalid_input", details } }, reusing the one shape from the top of this lesson. A single generic code with per-field details scales better than inventing email_invalid and password_too_short as separate top-level codes.
Why a 200 with an error inside is broken
Because everything that reads only the status code concludes the request succeeded, including caches, monitors, retry logic, and fetch's res.ok.
The status line is the machine-readable verdict, and the body only refines it. Hiding an error inside a 200 tells every piece of automated infrastructure that all is well, and only code that specifically inspects the body learns otherwise.
The consequences are concrete. Caches may store the failure and serve it to everyone, dashboards report 100 percent success while users see errors, and generic client code takes the success path and then crashes on missing fields.
Retry logic is the case that bites hardest in production. A transient failure returned as 500 gets retried automatically by most clients, and the same failure returned as 200 is treated as final, so a recoverable blip becomes a permanent error for the user.
This is why lesson 3-3's rule and this lesson's error shape work together. The status code is a truthful signal for machines, and one stable body shape carries the details for humans and client code, and neither can do the other's job.