lila/documentation/tickets/t00002.md
2026-04-28 13:18:18 +02:00

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

  • GameRounds is now 3 | 10 instead of "3" | "10"
  • Number(request.rounds) cast removed from gameService.ts
  • SettingGroup in GameSetup.tsx now accepts string | number options
  • useState<string> for rounds changed to useState<number>
  • Adding a new round count requires only editing GAME_ROUNDS in constants.ts
  • z.enum cannot be used for number literals — z.literal must be used instead (this is a Zod constraint, not a project convention)

Affected files

  • packages/shared/src/constants.ts
  • packages/shared/src/schemas/game.ts
  • apps/api/src/services/gameService.ts
  • apps/api/src/services/gameService.test.ts
  • apps/api/src/controllers/gameController.test.ts
  • apps/web/src/components/game/GameSetup.tsx

References


Setup guide / implementation notes

  1. In packages/shared/src/constants.ts, change:

    export const GAME_ROUNDS = ["3", "10"] as const;
    

    to:

    export const GAME_ROUNDS = [3, 10] as const;
    
  2. In packages/shared/src/schemas/game.ts, change:

    rounds: z.enum(GAME_ROUNDS),
    

    to:

    rounds: z.literal(GAME_ROUNDS),
    
  3. In apps/api/src/services/gameService.ts, change:

    Number(request.rounds),
    

    to:

    request.rounds,
    
  4. In apps/api/src/services/gameService.test.ts, change:

    rounds: "3",
    

    to:

    rounds: 3,
    
  5. 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);
    });
    
  6. In apps/web/src/components/game/GameSetup.tsx:

    • Update SettingGroup props to accept string | number:

      type SettingGroupProps = {
        options: readonly (string | number)[];
        selected: string | number;
        onSelect: (value: string | number) => void;
      };
      
    • Update LABELS lookup to LABELS[String(option)]

    • Change rounds state from useState<string> to useState<number>