The point of an error is to make debugging cheap. If reading the log line doesn't tell you what happened, what was expected, and where to look — the error failed at its job. This skill covers how to design and throw errors that carry that information every time.
Pairs with logging — errors get emitted through the global handler that adds request context, trace-id, and the full cause chain. Neither skill works without the other.
Example prompts
- "Add error handling for the user-lookup route"
- "Refactor these
throw new Error('X') calls to something typed"
- "What should the error shape look like for this API?"
- "Review this handler for error discipline"
Core principles
Every error self-explains. From the log line alone, a reader should know: what happened, what was expected, and where to look next. Error("not found") fails this test. NotFoundError("user", { id }) passes.
Codes over stack traces for categorization. A stable string code (e.g. USER_NOT_FOUND, TOKEN_EXPIRED, RATE_LIMITED) is greppable, dashboard-friendly, and stable across refactors. Stack traces are for one-off debugging, codes are for aggregation.
Distinguish expected from unexpected.
- Expected (validation, auth, not-found, conflict, rate-limit) — a normal outcome, log at WARN or INFO, return a clean response to the caller.
- Unexpected (a null dereference, downstream 500, DB unreachable) — a bug or infra failure, log at ERROR, do not leak details to the caller (return generic
500).
Throw at the boundary where the invariant breaks. If the DB says the row doesn't exist, throw there — not three call sites up. Callers should catch only when they can do something (retry, fallback, transform).
Never rethrow bare. catch (e) { throw e } is dead weight — remove it. If you catch, either enrich (throw new ServiceError("user lookup failed", { cause: e, code: "USER_LOOKUP" })) or handle (fallback, log-and-swallow with justification). Never swallow silently.
HTTP status maps from error type, not per-endpoint. A NotFoundError becomes 404 everywhere. A ValidationError becomes 400. Wire the mapping once in the global handler, not in every route.
The shape of an error
Whatever your language, an error carries these fields:
| Field |
Purpose |
type |
Class / typed error variant. NotFoundError, ValidationError, etc. |
code |
Stable string, SCREAMING_SNAKE. Grep-friendly, dashboard-friendly. |
message |
Human-readable, actionable. "User abc123 not found in projects table." |
cause |
The wrapped underlying error (chain), so the log has the full trail. |
status |
HTTP status code (for API errors only). Used by the global handler. |
context |
Any structured fields relevant to reproducing (ids, params). No secrets. |
TypeScript example (adapt to your stack):
export class AppError extends Error {
constructor(
message: string,
readonly code: string,
readonly status: number,
readonly context: Record<string, unknown> = {},
options?: { cause?: unknown }
) {
super(message, options);
this.name = this.constructor.name;
}
}
export class NotFoundError extends AppError {
constructor(resource: string, context: Record<string, unknown>) {
super(`${resource} not found`, `${resource.toUpperCase()}_NOT_FOUND`, 404, context);
}
}
Throw:
const user = await db.users.byId(id);
if (!user) throw new NotFoundError("user", { id });
Global error handler — the contract
Wire once. Every thrown error routes through here.
Responsibilities:
- Log the full error. Message + code + status + context + cause chain (walk
.cause recursively). This is where the logging skill's structure kicks in — see that skill for the shape.
- Map to HTTP response. Use
error.status if it's an AppError; default to 500 otherwise.
- Never leak internals on 500. Response body is
{ code: "INTERNAL", message: "internal error", trace_id }. The dev sees the real error in logs via trace_id.
- Preserve trace-id. Attach the request's trace-id to the response so users can quote it in support tickets.
Fastify example:
fastify.setErrorHandler((err, req, reply) => {
const isApp = err instanceof AppError;
req.log.error({
err,
code: isApp ? err.code : "INTERNAL",
context: isApp ? err.context : {},
trace_id: req.id,
}, "request failed");
const status = isApp ? err.status : 500;
reply.code(status).send({
code: isApp ? err.code : "INTERNAL",
message: isApp ? err.message : "internal error",
trace_id: req.id,
});
});
When to throw vs return
- Throw for exceptional conditions the current function cannot handle. Bad input at a validated boundary, missing entity in a lookup, downstream 500, etc.
- Return for expected results, including "not found" from a search (returning
null or [] is fine — an empty search is a valid outcome). Returning is cheaper than throwing and doesn't need a catch.
- Never throw for control flow.
throw new Error("break loop") is a code smell.
Rule of thumb: if the caller would immediately need to catch and translate, you should have returned instead.
Ponytail: don't overbuild the taxonomy
Two thousand error classes for a CRUD service is over-engineering. Start with a handful:
ValidationError (400)
UnauthorizedError (401)
ForbiddenError (403)
NotFoundError (404)
ConflictError (409)
RateLimitError (429)
AppError (500 — the generic fallback for typed internal errors)
Add a specialized class only when you have three callers who need the same recovery logic. Before three, throw new ValidationError('bad email', { field: 'email' }) is fine.
Anti-patterns
throw new Error(String(e)) — loses the cause chain. Use throw new WrapperError(msg, { cause: e }) or don't wrap at all.
catch (e) { console.error(e); throw e; } — the global handler logs. This is just noise + duplicate log entries.
catch (e) {} — silent swallow. If you must, comment why (// ponytail: swallow — this is a fire-and-forget cache miss).
throw "user not found" — throwing a string, not an Error. Loses stack, loses cause. Never do this.
- HTTP status decided in the route — should live on the error type. Route just
throws.
- Leaking DB error messages to the client — never send
duplicate key value violates unique constraint back over HTTP. Wrap in a domain error.
Reviewing for error discipline
When reviewing (or when code-review runs), check for:
- Every
throw uses a typed error, not new Error(...) (or the language's equivalent).
- Every
catch either enriches or handles — never bare rethrow, never silent swallow.
- The error path is tested (see
test-plan — "failure modes" is where these cases live).
- HTTP responses on 500 don't leak internals.
- Log lines for errors include code + trace-id + context (see
logging).
The story with logging
An error's job is to capture what went wrong. Logging's job is to emit it. The story:
- Bad input →
throw new ValidationError('email required', { field: 'email' }) at the validation layer.
- Global handler catches → logs
{ level: error, code: EMAIL_REQUIRED, trace_id: abc123, user_id: u_42, context: { field: email } } via the logging skill's structure.
- Handler responds
400 { code: EMAIL_REQUIRED, message: "email required", trace_id: abc123 }.
- Operator sees the log, greps the codebase for
EMAIL_REQUIRED → finds where it's thrown → uses trace_id to find related logs from the same request → reproduces.
If any step lacks context, debugging costs a rerun. That's the point of the discipline.
1---2name: errors3description: Design and throw errors that self-explain, so debugging from a log line is possible without rerunning the code. Every error carries a stable code, a human message, the cause chain, and (for API errors) an HTTP status. Distinguishes expected errors (validation, auth, not-found) from unexpected (bugs, infra down). Pairs with the `logging` skill — errors get logged with full context by the global handler. Use when adding a new error type, refactoring error handling, deciding whether to throw / return / wrap, writing an API route, or reviewing code for error discipline. Do not build a taxonomy for one endpoint — YAGNI applies.4---56The point of an error is to make debugging cheap. If reading the log line doesn't tell you what happened, what was expected, and where to look — the error failed at its job. This skill covers how to design and throw errors that carry that information every time.78Pairs with `logging` — errors get emitted through the global handler that adds request context, trace-id, and the full cause chain. Neither skill works without the other.910## Example prompts1112- "Add error handling for the user-lookup route"13- "Refactor these `throw new Error('X')` calls to something typed"14- "What should the error shape look like for this API?"15- "Review this handler for error discipline"1617## Core principles18191. **Every error self-explains.** From the log line alone, a reader should know: *what* happened, *what was expected*, and *where* to look next. `Error("not found")` fails this test. `NotFoundError("user", { id })` passes.20212. **Codes over stack traces for categorization.** A stable string code (e.g. `USER_NOT_FOUND`, `TOKEN_EXPIRED`, `RATE_LIMITED`) is greppable, dashboard-friendly, and stable across refactors. Stack traces are for one-off debugging, codes are for aggregation.22233. **Distinguish expected from unexpected.**24 - **Expected** (validation, auth, not-found, conflict, rate-limit) — a normal outcome, log at WARN or INFO, return a clean response to the caller.25 - **Unexpected** (a null dereference, downstream 500, DB unreachable) — a bug or infra failure, log at ERROR, do not leak details to the caller (return generic `500`).26274. **Throw at the boundary where the invariant breaks.** If the DB says the row doesn't exist, throw there — not three call sites up. Callers should catch only when they can *do* something (retry, fallback, transform).28295. **Never rethrow bare.** `catch (e) { throw e }` is dead weight — remove it. If you catch, either **enrich** (`throw new ServiceError("user lookup failed", { cause: e, code: "USER_LOOKUP" })`) or **handle** (fallback, log-and-swallow with justification). Never swallow silently.30316. **HTTP status maps from error type, not per-endpoint.** A `NotFoundError` becomes 404 everywhere. A `ValidationError` becomes 400. Wire the mapping once in the global handler, not in every route.3233## The shape of an error3435Whatever your language, an error carries these fields:3637| Field | Purpose |38| :------- | :----------------------------------------------------------------------- |39| `type` | Class / typed error variant. `NotFoundError`, `ValidationError`, etc. |40| `code` | Stable string, `SCREAMING_SNAKE`. Grep-friendly, dashboard-friendly. |41| `message`| Human-readable, actionable. "User `abc123` not found in projects table." |42| `cause` | The wrapped underlying error (chain), so the log has the full trail. |43| `status` | HTTP status code (for API errors only). Used by the global handler. |44| `context`| Any structured fields relevant to reproducing (ids, params). No secrets. |4546TypeScript example (adapt to your stack):4748```ts49export class AppError extends Error {50 constructor(51 message: string,52 readonly code: string,53 readonly status: number,54 readonly context: Record<string, unknown> = {},55 options?: { cause?: unknown }56 ) {57 super(message, options);58 this.name = this.constructor.name;59 }60}6162export class NotFoundError extends AppError {63 constructor(resource: string, context: Record<string, unknown>) {64 super(`${resource} not found`, `${resource.toUpperCase()}_NOT_FOUND`, 404, context);65 }66}67```6869Throw:7071```ts72const user = await db.users.byId(id);73if (!user) throw new NotFoundError("user", { id });74```7576## Global error handler — the contract7778Wire once. Every thrown error routes through here.7980**Responsibilities:**81821. **Log the full error.** Message + code + status + context + cause chain (walk `.cause` recursively). This is where the `logging` skill's structure kicks in — see that skill for the shape.832. **Map to HTTP response.** Use `error.status` if it's an `AppError`; default to 500 otherwise.843. **Never leak internals on 500.** Response body is `{ code: "INTERNAL", message: "internal error", trace_id }`. The dev sees the real error in logs via `trace_id`.854. **Preserve trace-id.** Attach the request's trace-id to the response so users can quote it in support tickets.8687Fastify example:8889```ts90fastify.setErrorHandler((err, req, reply) => {91 const isApp = err instanceof AppError;92 req.log.error({93 err,94 code: isApp ? err.code : "INTERNAL",95 context: isApp ? err.context : {},96 trace_id: req.id,97 }, "request failed");9899 const status = isApp ? err.status : 500;100 reply.code(status).send({101 code: isApp ? err.code : "INTERNAL",102 message: isApp ? err.message : "internal error",103 trace_id: req.id,104 });105});106```107108## When to throw vs return109110- **Throw** for exceptional conditions the current function cannot handle. Bad input at a validated boundary, missing entity in a lookup, downstream 500, etc.111- **Return** for expected results, including "not found" from a *search* (returning `null` or `[]` is fine — an empty search is a valid outcome). Returning is cheaper than throwing and doesn't need a `catch`.112- **Never** throw for control flow. `throw new Error("break loop")` is a code smell.113114Rule of thumb: if the caller would immediately need to catch and translate, you should have returned instead.115116## Ponytail: don't overbuild the taxonomy117118Two thousand error classes for a CRUD service is over-engineering. Start with a handful:119120- `ValidationError` (400)121- `UnauthorizedError` (401)122- `ForbiddenError` (403)123- `NotFoundError` (404)124- `ConflictError` (409)125- `RateLimitError` (429)126- `AppError` (500 — the generic fallback for typed internal errors)127128Add a specialized class only when you have three callers who need the *same* recovery logic. Before three, `throw new ValidationError('bad email', { field: 'email' })` is fine.129130## Anti-patterns131132- **`throw new Error(String(e))`** — loses the cause chain. Use `throw new WrapperError(msg, { cause: e })` or don't wrap at all.133- **`catch (e) { console.error(e); throw e; }`** — the global handler logs. This is just noise + duplicate log entries.134- **`catch (e) {}`** — silent swallow. If you *must*, comment why (`// ponytail: swallow — this is a fire-and-forget cache miss`).135- **`throw "user not found"`** — throwing a string, not an Error. Loses stack, loses cause. Never do this.136- **HTTP status decided in the route** — should live on the error type. Route just `throw`s.137- **Leaking DB error messages to the client** — never send `duplicate key value violates unique constraint` back over HTTP. Wrap in a domain error.138139## Reviewing for error discipline140141When reviewing (or when `code-review` runs), check for:142143- Every `throw` uses a typed error, not `new Error(...)` (or the language's equivalent).144- Every `catch` either enriches or handles — never bare rethrow, never silent swallow.145- The error path is tested (see `test-plan` — "failure modes" is where these cases live).146- HTTP responses on 500 don't leak internals.147- Log lines for errors include code + trace-id + context (see `logging`).148149## The story with logging150151An error's job is to *capture* what went wrong. Logging's job is to *emit* it. The story:1521531. Bad input → `throw new ValidationError('email required', { field: 'email' })` at the validation layer.1542. Global handler catches → logs `{ level: error, code: EMAIL_REQUIRED, trace_id: abc123, user_id: u_42, context: { field: email } }` via the `logging` skill's structure.1553. Handler responds `400 { code: EMAIL_REQUIRED, message: "email required", trace_id: abc123 }`.1564. Operator sees the log, greps the codebase for `EMAIL_REQUIRED` → finds where it's thrown → uses `trace_id` to find related logs from the same request → reproduces.157158If any step lacks context, debugging costs a rerun. That's the point of the discipline.