Skip to content

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

FileChange
packages/types/src/index.tsNew package — all core game types extracted here (@band-game/types)
apps/server/src/game/game.service.tsImport from @band-game/types; type moveData params; fix Firestore cast pattern
apps/server/src/game/game-state-cache.service.tsCacheEntry.gameState: anyGameState; typed get()/set()
apps/server/src/game/game.gateway.tsType moveData in makeMove socket payload
apps/server/package.jsonAdd "@band-game/types": "workspace:*" dependency
apps/server/eslint.config.mjsRestore 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.tsObject.entries(data) in updateDocument where data: any is 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 architecturepackages/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/types instead of defining them inline
  • Change getGameState() to cast the DB result to RawGameStateDoc (not any) before converting timestamps
  • Change moveData: anymoveData: GameMoveData in makeMove() and validateMove()
  • Fix saveGameState cast: { ...gameState } as GameState instead of as any

Step 3 — Update game-state-cache.service.ts

  • Import GameState from @band-game/types
  • Change CacheEntry.gameState: anyGameState
  • Change get() return type to GameState | null
  • Change set() gameState parameter to GameState

Step 4 — Update game.gateway.ts

  • Import GameMoveData from @band-game/types
  • Type the makeMove socket event payload: { gameId: string; playerId: string; moveType: string; moveData: GameMoveData }
  • Type the moveCompleted emit payload to use GameMoveData instead of any

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-assertion back to error
  • Keep no-unsafe-argument as warn (was warn before this work)
  • Keep require-await and no-redundant-type-constituents as warn (minor style issues)
  • Add an explicit comment explaining that database.service.ts Drizzle 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                             # clean

Also 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.