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

@ -1,11 +1,14 @@
import express from "express";
import type { Express } from "express";
import { apiRouter } from "./routes/apiRouter.js";
import { errorHandler } from "./middleware/errorHandler.js";
export function createApp() {
const app: Express = express();
app.use(express.json());
app.use("/api/v1", apiRouter);
app.use(errorHandler);
return app;
}

View file

@ -1,32 +1,42 @@
import type { Request, Response } from "express";
import type { Request, Response, NextFunction } from "express";
import { GameRequestSchema, AnswerSubmissionSchema } from "@glossa/shared";
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
import { ValidationError } from "../errors/AppError.js";
export const createGame = async (req: Request, res: Response) => {
const gameSettings = GameRequestSchema.safeParse(req.body);
export const createGame = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const gameSettings = GameRequestSchema.safeParse(req.body);
// TODO: remove when global error handler is implemented
if (!gameSettings.success) {
res.status(400).json({ success: false });
return;
if (!gameSettings.success) {
throw new ValidationError(gameSettings.error.message);
}
const gameQuestions = await createGameSession(gameSettings.data);
res.json({ success: true, data: gameQuestions });
} catch (error) {
next(error);
}
const gameQuestions = await createGameSession(gameSettings.data);
res.json({ success: true, data: gameQuestions });
};
export const submitAnswer = async (req: Request, res: Response) => {
const submission = AnswerSubmissionSchema.safeParse(req.body);
// TODO: remove when global error handler is implemented
if (!submission.success) {
res.status(400).json({ success: false });
return;
}
export const submitAnswer = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const submission = AnswerSubmissionSchema.safeParse(req.body);
if (!submission.success) {
throw new ValidationError(submission.error.message);
}
const result = await evaluateAnswer(submission.data);
res.json({ success: true, data: result });
} catch (error) {
res.status(404).json({ success: false });
next(error);
}
};

View file

@ -0,0 +1,21 @@
export class AppError extends Error {
public readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 404);
}
}

View file

@ -0,0 +1,18 @@
import type { Request, Response, NextFunction } from "express";
import { AppError } from "../errors/AppError.js";
export const errorHandler = (
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
) => {
if (err instanceof AppError) {
res.status(err.statusCode).json({ success: false, error: err.message });
return;
}
console.error("Unexpected error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
};

View file

@ -8,8 +8,8 @@ import type {
AnswerSubmission,
AnswerResult,
} from "@glossa/shared";
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
import { NotFoundError } from "../errors/AppError.js";
const gameSessionStore = new InMemoryGameSessionStore();
@ -39,7 +39,6 @@ export const createGameSession = async (
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts);
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
@ -64,6 +63,7 @@ export const createGameSession = async (
return { sessionId, questions };
};
const shuffle = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
@ -81,13 +81,13 @@ export const evaluateAnswer = async (
const session = await gameSessionStore.get(submission.sessionId);
if (!session) {
throw new Error(`Game session not found: ${submission.sessionId}`);
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
}
const correctOptionId = session.answers.get(submission.questionId);
if (correctOptionId === undefined) {
throw new Error(`Question not found: ${submission.questionId}`);
throw new NotFoundError(`Question not found: ${submission.questionId}`);
}
return {

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`

View file

@ -34,6 +34,25 @@ async function main() {
`${question.prompt}: ${result.data.isCorrect ? "✓" : "✗"} (picked ${0}, correct was ${result.data.correctOptionId})`,
);
}
const badRequest = await fetch("http://localhost:3000/api/v1/game/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_language: "en" }),
});
console.log("400 test:", badRequest.status, await badRequest.json());
// Send a valid shape but a session that doesn't exist
const notFound = await fetch("http://localhost:3000/api/v1/game/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "00000000-0000-0000-0000-000000000000",
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
}),
});
console.log("404 test:", notFound.status, await notFound.json());
}
main();