# API Error Handling

> Extend the kit's central Express error handler and typed error contract end to end: domain errors carrying a statusCode, ZodError→400 at the boundary, 500s that never leak internals, and frontend mapping of ApiError into user-facing states.

- Skill: `thibautbaissac/api-error-handling` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add thibautbaissac/api-error-handling`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thibautbaissac/api-error-handling/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: thibautbaissac (https://skillmd.com/u/thibautbaissac)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thibautbaissac/api-error-handling

---


# Error handling, end to end

Build on what the kit already ships — do not invent a new error envelope:
- `src/apiApp.ts` has one central `errorHandler`: `ZodError → 400`, otherwise it
  reads `err.statusCode` and hides the message on `5xx`.
- `src/shared/lib/api.ts` throws `ApiError(statusCode, body)` on a non-OK fetch.

Your job is to feed that handler typed domain errors and map `ApiError` in the UI.

## Step 1 — Define domain errors that carry a statusCode

Apply `references/error-patterns.md`. Add small error classes with a numeric
`statusCode` so the central handler maps them automatically: `NotFoundError`
(404), `UnauthorizedError` (401), `ForbiddenError` (403), `ConflictError` (409).
Put generic ones in `shared/lib/errors.ts`; keep feature-specific ones in the slice.

## Step 2 — Throw from services, catch in routes

Services throw domain errors; they never touch `req`/`res`. Routes `try/catch`
and forward with `next(err)` — never build ad-hoc error responses. The central
handler owns status and body shaping.

## Step 3 — Parse at the boundary

Keep parsing `req.body`/`params`/`query` with zod at the route. A failed
`.parse()` throws `ZodError`, which the central handler already turns into a
`400` with `issues` — no extra code needed.

## Step 4 — Don't leak existence or internals

- Where ownership matters, throw `NotFoundError` for resources the principal does
  not own (coordinate with `/scaffold-auth`).
- Never put secrets, stack traces, or raw DB errors in a response. The handler
  already masks `5xx` messages — keep `statusCode` off internal errors so they
  stay `500`.

## Step 5 — Map ApiError in the frontend

The query/mutation hook lets `ApiError` propagate; the component maps
`error.statusCode` to a user-facing message (401 → sign in, 404 → not found,
5xx → generic). Never render `error.body` raw. Pair with route `ErrorBoundary`
from `/react-router` for thrown render-time errors.

## Step 6 — Verify

```bash
source "${CLAUDE_PROJECT_DIR}/.claude/lib/detect-toolchain.sh"
run_typecheck
run_tests <feature>
```

Prove the codes: a route test asserting `400` (invalid body), `401`/`404` where
relevant, and that a thrown `5xx` response body contains no internal detail.

## Checklist

- [ ] Domain errors carry a numeric `statusCode`; the central handler maps them.
- [ ] Services throw; routes `next(err)`; no ad-hoc error responses or `req`/`res` in services.
- [ ] Boundary parsing stays at the route; `ZodError → 400` via the central handler.
- [ ] No secrets/stack traces in responses; `5xx` messages stay masked.
- [ ] Frontend maps `ApiError.statusCode` to messages; never renders raw bodies.
- [ ] Status-code tests pass; `run_typecheck` passes.

