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;
// 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<T> relationship through
// index lookup, so `on`/`off`/`emit` can manipulate listener arrays without
// any casts.
type ListenerTable = {
[T in GameClientEventType]?: Listener<T>[];
};
// ---------------------------------------------------------------------------
// 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<GameClientEventType, AnyListener[]>();
// Listeners keyed by event type. Mapped-type keys preserve the
// per-type Listener<T> relationship; see `ListenerTable`.
private readonly listeners: ListenerTable = {};
constructor(url: string, options: GameClientOptions = {}) {
this.url = url;
@ -175,20 +169,21 @@ export class GameClient {
// -------------------------------------------------------------------------
on<T extends GameClientEventType>(type: T, listener: Listener<T>): 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<T, …>` narrows the write site
// safely — this is the only place that widening happens, and it
// preserves the per-type Listener<T> relationship elsewhere.
const table = this.listeners as Record<T, Listener<T>[] | undefined>;
const arr = table[type];
if (arr) arr.push(listener);
else table[type] = [listener];
}
off<T extends GameClientEventType>(type: T, listener: Listener<T>): 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<T extends GameClientEventType>(event: EventOfType<T>): void {
// Generic over T so `this.listeners[event.type]` resolves to
// `Listener<T>[] | undefined` (not a union), which matches the
// concrete `event: EventOfType<T>` 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.