4.3 KiB
ADR: Change GAME_ROUNDS from strings to numbers
Status
Accepted
Date
2026-04-28
Context
GAME_ROUNDS in packages/shared/src/constants.ts was typed as ["3", "10"] as const, making GameRounds a string union ("3" | "10"). This meant gameService.ts had to cast the value with Number(request.rounds) deep in business logic — a type conversion happening far from the boundary where data enters the system. The type system was lying: rounds was described as a string everywhere but used as a number where it mattered.
Decision
Change GAME_ROUNDS to [3, 10] as const and update the Zod schema to use z.literal(GAME_ROUNDS) instead of z.enum(GAME_ROUNDS). The single source of truth remains constants.ts — adding a new round count (e.g. 20) requires only editing that file.
Options considered
Option A — Numbers everywhere ✅
Change GAME_ROUNDS to [3, 10] as const. Use z.literal(GAME_ROUNDS) in the schema. Update the frontend component state and SettingGroup props. Drop Number() cast in the service.
Chosen because: JSON carries numbers natively, both ends of the wire are owned by this codebase, and type conversions belong at the boundary — not inside business logic.
Option B — Keep strings, accept the cast
Leave GAME_ROUNDS as ["3", "10"]. The Number() cast stays in gameService.ts.
Rejected because: it pushes type conversion into business logic and makes the inferred GameRequest type misleading. The cast has to live somewhere — the schema boundary is the right place.
Option C — Coerce at the schema boundary
Keep GAME_ROUNDS as numbers but use z.coerce.number().pipe(z.literal(GAME_ROUNDS)) so the frontend can keep sending strings.
Rejected because: coercion is for untrusted or uncontrolled inputs (form fields, query params, third-party clients). We control both ends of the wire. Coercing a self-inflicted type mismatch is treating a wound we gave ourselves.
Consequences
GameRoundsis now3 | 10instead of"3" | "10"Number(request.rounds)cast removed fromgameService.tsSettingGroupinGameSetup.tsxnow acceptsstring | numberoptionsuseState<string>for rounds changed touseState<number>- Adding a new round count requires only editing
GAME_ROUNDSinconstants.ts z.enumcannot be used for number literals —z.literalmust be used instead (this is a Zod constraint, not a project convention)
Affected files
packages/shared/src/constants.tspackages/shared/src/schemas/game.tsapps/api/src/services/gameService.tsapps/api/src/services/gameService.test.tsapps/api/src/controllers/gameController.test.tsapps/web/src/components/game/GameSetup.tsx
References
Setup guide / implementation notes
-
In
packages/shared/src/constants.ts, change:export const GAME_ROUNDS = ["3", "10"] as const;to:
export const GAME_ROUNDS = [3, 10] as const; -
In
packages/shared/src/schemas/game.ts, change:rounds: z.enum(GAME_ROUNDS),to:
rounds: z.literal(GAME_ROUNDS), -
In
apps/api/src/services/gameService.ts, change:Number(request.rounds),to:
request.rounds, -
In
apps/api/src/services/gameService.test.ts, change:rounds: "3",to:
rounds: 3, -
In
apps/api/src/controllers/gameController.test.ts, change:rounds: "3",to:
rounds: 3,Also add a pinning test before the refactor:
it("returns 400 when rounds has an invalid value", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ ...validBody, rounds: "invalid" }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); -
In
apps/web/src/components/game/GameSetup.tsx:-
Update
SettingGroupprops to acceptstring | number:type SettingGroupProps = { options: readonly (string | number)[]; selected: string | number; onSelect: (value: string | number) => void; }; -
Update
LABELSlookup toLABELS[String(option)] -
Change rounds state from
useState<string>touseState<number>
-