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:
parent
00533167b4
commit
a39b9921c6
1 changed files with 26 additions and 31 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue