refactor(net): type listener table as mapped type, drop double-casts

The listener map was typed as Map<GameClientEventType, AnyListener[]>, which
erased the per-type Listener<T> 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<T>[] }`.
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<T, …>` 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).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 16:48:35 -06:00
commit a39b9921c6
No known key found for this signature in database

View file

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