diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index 13ae45b..a8fa42b 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -59,20 +59,13 @@ type EventOfType = Extract< type Listener = (event: EventOfType) => void; -// Heterogeneous internal listener map — narrowed via the public `on()` API. -// Using `unknown` avoids `any` while still permitting one map for all types. -type AnyListener = (event: GameClientEvent) => void; - -// Shape emitted to listeners of a lifecycle-only event. We accept these two -// shapes when callers invoke `emit()` so the compiler stays honest about the -// discriminated union without resorting to casts. -interface LifecycleConnected { - type: "connected"; -} -interface LifecycleDisconnected { - type: "disconnected"; - willReconnect: boolean; -} +// Heterogeneous internal listener table. Typing as a mapped type keyed by +// the discriminant preserves the per-event Listener relationship through +// index lookup, so `on`/`off`/`emit` can manipulate listener arrays without +// any casts. +type ListenerTable = { + [T in GameClientEventType]?: Listener[]; +}; // --------------------------------------------------------------------------- // Configuration @@ -142,8 +135,9 @@ export class GameClient { // `closed` is set when close() is called: it suppresses auto-reconnect. private closed = false; - // Listeners keyed by event type. - private readonly listeners = new Map(); + // Listeners keyed by event type. Mapped-type keys preserve the + // per-type Listener relationship; see `ListenerTable`. + private readonly listeners: ListenerTable = {}; constructor(url: string, options: GameClientOptions = {}) { this.url = url; @@ -175,20 +169,21 @@ export class GameClient { // ------------------------------------------------------------------------- on(type: T, listener: Listener): void { - const arr = this.listeners.get(type); - // We up-cast to AnyListener here because the map is heterogeneous; the - // public `on` signature guarantees each listener only ever receives its - // own discriminated variant, which we enforce at `emit()` sites. - const cast = listener as unknown as AnyListener; - if (arr) arr.push(cast); - else this.listeners.set(type, [cast]); + // TS can't prove writes to `this.listeners[type]` are safe for a + // generic T (the mapped-type key makes the target an intersection). + // Projecting to a per-call `Record` narrows the write site + // safely — this is the only place that widening happens, and it + // preserves the per-type Listener relationship elsewhere. + const table = this.listeners as Record[] | undefined>; + const arr = table[type]; + if (arr) arr.push(listener); + else table[type] = [listener]; } off(type: T, listener: Listener): void { - const arr = this.listeners.get(type); + const arr = this.listeners[type]; if (!arr) return; - const cast = listener as unknown as AnyListener; - const idx = arr.indexOf(cast); + const idx = arr.indexOf(listener); if (idx >= 0) arr.splice(idx, 1); } @@ -473,11 +468,11 @@ export class GameClient { } } - private emit(event: GameClientEvent): void; - private emit(event: LifecycleConnected): void; - private emit(event: LifecycleDisconnected): void; - private emit(event: GameClientEvent): void { - const arr = this.listeners.get(event.type); + private emit(event: EventOfType): void { + // Generic over T so `this.listeners[event.type]` resolves to + // `Listener[] | undefined` (not a union), which matches the + // concrete `event: EventOfType` being dispatched. No casts needed. + const arr = this.listeners[event.type]; if (!arr) return; // Iterate over a copy so listeners that call `off()` mid-dispatch don't // skip subsequent listeners.