Error handling, end to end
Build on what the kit already ships — do not invent a new error envelope:
src/apiApp.tshas one centralerrorHandler:ZodError → 400, otherwise it readserr.statusCodeand hides the message on5xx.src/shared/lib/api.tsthrowsApiError(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
NotFoundErrorfor 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
5xxmessages — keepstatusCodeoff internal errors so they stay500.
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
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 orreq/resin services. - Boundary parsing stays at the route;
ZodError → 400via the central handler. - No secrets/stack traces in responses;
5xxmessages stay masked. - Frontend maps
ApiError.statusCodeto messages; never renders raw bodies. - Status-code tests pass;
run_typecheckpasses.