feat(api): add global error handler with typed error classes

- Add AppError base class, ValidationError (400), NotFoundError (404)
- Add central error middleware in app.ts
- Remove inline safeParse error handling from controllers
- Replace plain Error throws with NotFoundError in gameService
This commit is contained in:
lila 2026-04-12 08:48:43 +02:00
parent dd6c2b0118
commit 48457936e8
7 changed files with 114 additions and 24 deletions

View file

@ -128,6 +128,25 @@ touches the database. A service never reads `req.body`. A model never knows what
- No fallback logic for insufficient distractors. Data volumes are sufficient; strict query throws if something is genuinely broken.
- Distractor query excludes both the correct term ID and the correct answer text, preventing duplicate options from different terms with the same translation.
- Submit-before-send flow on frontend: user selects, then confirms. Prevents misclicks.
- AppError base class over error code maps. A statusCode on the error itself means the middleware doesn't need a lookup table. New error types are self-contained — one class, one status code.
- next(error) over res.status().json() in controllers. Express requires explicit next(error) for async handlers. Centralises all error formatting in one place. Controllers stay clean — validate, call service, send response.
- Zod .message over .issues[0]?.message. Returns all validation failures, not just the first. Output is verbose (raw JSON string) — revisit formatting post-MVP if the frontend needs structured error objects.
---
## Global error handler: typed error classes + central middleware
Three-layer pattern: error classes define the shape, services throw them, middleware catches them.
AppError is the base class — carries a statusCode and a message. ValidationError (400) and NotFoundError (404) extend it. Adding a new error type is one class with a super() call.
Controllers wrap their body in try/catch and call next(error) in the catch block. They never build error responses themselves. This is required because Express does not catch errors from async handlers automatically — without next(error), an unhandled rejection crashes the process.
The middleware in app.ts (registered after all routes) checks instanceof AppError. Known errors get their statusCode and message. Unknown errors get logged and return a generic 500 — no stack traces leak to the client.
Zod validation error format: used gameSettings.error.message rather than gameSettings.error.issues[0]?.message. This sends all validation failures at once instead of just the first. Tradeoff: the output is a raw JSON string, not a clean object. Acceptable for MVP — if the frontend needs structured errors later, format .issues into { field, message }[] in the ValidationError constructor.
Where errors are thrown: ValidationError is thrown in the controller (it's the layer that runs safeParse). NotFoundError is thrown in the service (it's the layer that knows whether a session or question exists). The service still doesn't know about HTTP — it throws a typed error, and the middleware maps it to a status code.
---
@ -288,7 +307,7 @@ Required before: implementing the double join for source language prompt.
- `POST /api/v1/game/answer` route, controller, service method
### Step 6 — Global error handler
### Step 6 — Global error handler - done
- Typed error classes (`ValidationError`, `NotFoundError`)
- Central error middleware in `app.ts`