TypeScript Typing Improvements
Status: Complete — May 2026
Why
The server's ESLint CI gate runs with recommendedTypeChecked, which includes strict no-unsafe-* rules designed to catch real bugs from untyped any values. These rules were failing across the game service, cache service, and gateway because core types (GameMove.data, GameState, socket payloads) were loosely typed or entirely any.
The short-term fix was to downgrade those rules to warn. This plan restores them to error properly.
Scope
What changes
| File | Change |
|---|---|
packages/types/src/index.ts | New package — all core game types extracted here (@band-game/types) |
apps/server/src/game/game.service.ts | Import from @band-game/types; type moveData params; fix Firestore cast pattern |
apps/server/src/game/game-state-cache.service.ts | CacheEntry.gameState: any → GameState; typed get()/set() |
apps/server/src/game/game.gateway.ts | Type moveData in makeMove socket payload |
apps/server/package.json | Add "@band-game/types": "workspace:*" dependency |
apps/server/eslint.config.mjs | Restore unsafe-* rules to error; leave Drizzle workarounds as warn with a comment |
What is explicitly deferred
database.service.ts and match-start.service.ts as any casts have been resolved. The remaining 3 no-unsafe-argument warnings (intentionally warn in the ESLint config) are:
database.service.ts—Object.entries(data)inupdateDocumentwheredata: anyis the public parameter type; fixing it requires narrowing the boundary type.game.service.ts:601— one loose cast in the game service.
These stay as warn.
Step-by-Step Implementation
Step 1 — Create packages/types
Extract and improve the existing interfaces from game.service.ts into a shared workspace package. This is Phase 1 of the shared packages architecture — packages/api-contracts, packages/db-schema, and packages/ui-types are deferred to a later phase.
The package exposes GameState, GameMove, GameMoveData, PlayBandData, BandEntry, TurnTimer, GamePlayer, and RawGameStateDoc from @band-game/types.
typescript
// packages/types/src/index.ts
export interface PlayBandData {
bandName: string;
submitted?: string; // original submission when canonical name differs
}
export type GameMoveData = PlayBandData; // extend as new move types are added
export interface GameMove {
playerId: string;
moveType: string;
data: GameMoveData;
timestamp: Date;
valid: boolean;
invalidReason?: string;
round?: number;
}
export interface TurnTimer {
playerId: string;
turnStartedAt: Date;
maxTurnTime: number;
}
export interface GamePlayer {
id: string;
name: string;
socketId: string;
}
export interface GameState {
id: string;
players: GamePlayer[];
status: 'waiting' | 'active' | 'completed' | 'abandoned';
currentTurn: string;
moves: GameMove[];
gameMode: string;
createdAt: Date;
completedAt?: Date;
winner?: string;
lastBandName?: string;
currentTimer?: TurnTimer;
maxTurnTimeMs?: number;
currentRound?: number;
}
export interface BandEntry {
id: string;
bandName: string;
playerId: string;
gameId: string;
isValid: boolean;
revealed: boolean;
timestamp: Date;
round: number;
followerCount?: number | null;
}
// Raw Firestore document shape — dates are Firestore Timestamps before conversion
export interface RawGameStateDoc {
id: string;
players: GamePlayer[];
status: GameState['status'];
currentTurn: string;
moves: Array<Omit<GameMove, 'timestamp'> & { timestamp: { _seconds: number } }>;
gameMode: string;
createdAt: { _seconds: number };
completedAt?: { _seconds: number };
winner?: string;
lastBandName?: string;
currentTimer?: Omit<TurnTimer, 'turnStartedAt'> & { turnStartedAt: { _seconds: number } };
maxTurnTimeMs?: number;
currentRound?: number;
}GameMoveData is a type alias today (= PlayBandData). When a second move type is added, it becomes a discriminated union — call sites don't change.
Step 2 — Update game.service.ts
- Import all types from
@band-game/typesinstead of defining them inline - Change
getGameState()to cast the DB result toRawGameStateDoc(notany) before converting timestamps - Change
moveData: any→moveData: GameMoveDatainmakeMove()andvalidateMove() - Fix
saveGameStatecast:{ ...gameState } as GameStateinstead ofas any
Step 3 — Update game-state-cache.service.ts
- Import
GameStatefrom@band-game/types - Change
CacheEntry.gameState: any→GameState - Change
get()return type toGameState | null - Change
set()gameStateparameter toGameState
Step 4 — Update game.gateway.ts
- Import
GameMoveDatafrom@band-game/types - Type the
makeMovesocket event payload:{ gameId: string; playerId: string; moveType: string; moveData: GameMoveData } - Type the
moveCompletedemit payload to useGameMoveDatainstead ofany
Step 5 — Restore ESLint rules
In eslint.config.mjs:
- Restore
no-unsafe-assignment,no-unsafe-member-access,no-unsafe-call,no-unsafe-return,no-unnecessary-type-assertionback toerror - Keep
no-unsafe-argumentaswarn(was warn before this work) - Keep
require-awaitandno-redundant-type-constituentsaswarn(minor style issues) - Add an explicit comment explaining that
database.service.tsDrizzle workarounds are a known deferred item
Verification
bash
pnpm --filter @band-game/server exec eslint "{src,apps,libs,test}/**/*.ts" # zero errors
pnpm --filter @band-game/server exec vitest run # 87 tests pass
pnpm --filter @band-game/server exec tsc --noEmit # cleanAlso manually confirm that game-state-cache.service.ts's get() return type of GameState | null is accepted at all call sites in game.service.ts without casts.