From a39b9921c63dc06108c2bc7f0f8176434a40f027 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 16:48:35 -0600 Subject: [PATCH] refactor(net): type listener table as mapped type, drop double-casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listener map was typed as Map, which erased the per-type Listener relationship and forced `as unknown as AnyListener` double-casts at every on()/off()/emit() site. Replace with a mapped-type record `{ [T in GameClientEventType]?: Listener[] }`. TS index lookup preserves the per-key relationship, so: - off() has no cast - emit() becomes generic over T and dispatches without casts - on() retains a single scoped `Record` projection at the write site (TS can't prove writes to a mapped-type index are safe under a generic T; this is a known limitation and the smallest workaround) Also drops the now-unused LifecycleConnected/LifecycleDisconnected interfaces (they existed only as emit() overload signatures, no longer needed with the generic emit). --- packages/chess/src/net/client.ts | 57 +++++++++++++++----------------- 1 file changed, 26 insertions(+), 31 deletions(-) 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.