3.8 KB112 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8class EventWithPayload<T> extends Event {
9 constructor(
10 type: string,
11 public data: T | Error,
12 ) {
13 super(type);
14 }
15}
16
17type TypedListenerParam<T> = ((data: T) => void) | ((err: Error) => void);
18
19/**
20 * Like {@link EventEmitter} / {@link EventTarget}, but with type checking for one particular subscription type,
21 * plus errors. uses EventTarget so it works in browser and node.
22 * ```
23 * const myEmitter = new TypedEventEmitter<'data', number>();
24 * myEmitter.on('data', (data: number) => ...); // typechecks 'data' param and callback
25 * myEmitter.on('error', (error: Error) => ...); // errors are always allowed too
26 * // Fields other than 'data' and 'error' are type errors.
27 * ```
28 */
29export class TypedEventEmitter<EventName extends string, EventType> {
30 private listeners: {[key: string]: Map<TypedListenerParam<EventType>, EventListener>} = {};
31
32 private target = new EventTarget();
33
34 on(event: EventName, listener: (data: EventType) => void): this;
35 on(event: 'error', listener: (err: Error) => void): this;
36 on(event: EventName | 'error', listener: TypedListenerParam<EventType>): this {
37 const map = this.getOrCreateMap(event);
38 let found = map.get(listener);
39 if (found == null) {
40 found = (event: Event | EventWithPayload<EventType>) => {
41 if ('data' in event) {
42 (listener as (data: EventType | Error) => void)(event.data);
43 }
44 };
45 map.set(listener, found);
46 }
47 this.target.addEventListener(event as EventName | 'error', found);
48 return this;
49 }
50
51 off(event: EventName, listener: (data: EventType) => void): this;
52 off(event: 'error', listener: (err: Error) => void): this;
53 off(event: EventName | 'error', listener: TypedListenerParam<EventType>): this {
54 const map = this.getOrCreateMap(event);
55 const found = map.get(listener);
56 if (found == null) {
57 return this;
58 }
59 map.delete(listener);
60 this.target.removeEventListener(event as EventName | 'error', found);
61 return this;
62 }
63
64 emit(event: EventName): EventType extends undefined ? boolean : never;
65 emit(event: EventName, data: EventType): boolean;
66 emit(event: 'error', data: Error): boolean;
67
68 emit(name: EventName | 'error', data?: EventType | Error): boolean {
69 const event = new EventWithPayload(name, data);
70 if (!this.target.dispatchEvent(event)) {
71 return false;
72 }
73 return true;
74 }
75
76 private getOrCreateMap(event: EventName | 'error') {
77 return (this.listeners[event] ??= new Map());
78 }
79
80 removeAllListeners() {
81 for (const [key, map] of Object.entries(this.listeners)) {
82 for (const listener of map.values()) {
83 this.target.removeEventListener(key, listener);
84 }
85 map.clear();
86 }
87 }
88
89 /** Get an EventTarget-compatible object while still being able to use the typed APIs */
90 asEventTarget(): EventTarget {
91 const listeners = new Map<(event: Event) => void, (data: EventType) => void>();
92 return {
93 addEventListener: (type: EventName, handler: (event: Event) => void) => {
94 const wrapped = (data: EventType) => {
95 handler(new EventWithPayload<EventType>(type, data));
96 };
97 listeners.set(handler, wrapped);
98 this.on(type as EventName, wrapped);
99 },
100 removeEventListener: (type: EventName, handler: (event: Event) => void) => {
101 const existing = listeners.get(handler);
102 if (existing) {
103 this.off(type as EventName, existing);
104 listeners.delete(handler);
105 }
106 },
107 dispatchEvent: (event: EventWithPayload<EventType>) =>
108 this.emit(event.type as EventName, event.data as EventType),
109 } as EventTarget;
110 }
111}
112