It is well known that TypeScript allows you to provide typings for class-based event programming so there is autocompletion and type inference when listening to an event in, say, a DOM node or signaliser. This has been always typically done through overriding things like EventTarget.addEventListener() with custom listener call signatures. However, this can easily annoy, because these signatures look way complex and repetitive, and you've to override not just addEventListener(), but also removeEventListener() and maybe dispachEvent() and optionally provide non-typed signatures (in case you want to avoid .on('randomstring' as any, listener) and have same web DOM behavior).
I've designed an easy-to-use pattern that relies on the TypeScript type system. It involves defining an EventTarget oriented to rely on a global, "unique ECMAScript symbol".
The type model here works apparently exactly like the web DOM's EventTarget declarations. You can see that autocompletion in the TypeScript playground works a bit lazier; it'll only start autocompleting when typing at least one character inside the string literal - this is the same case for the web DOM when you use the DOM APIs there in the playground.
Here's what it looks like:
// A
class A extends EventTarget {
declare [EventRecord]: {
ready: Event,
};
}
// B
class B extends A {
declare [EventRecord]: A[typeof EventRecord] & {
updated: CustomEvent<number>,
};
}
// C
class C extends B {
declare [EventRecord]: B[typeof EventRecord] & {
beep: CustomEvent<boolean>,
};
}
// usage
const obj = new C();
obj.on("beep", e => {});
obj.on("beep", e => 10); // ERROR!
obj.on("randomstring", e => {}); // e: Event
obj.emit("beep", CustomEvent<boolean>, { detail: true });
obj.emit("a", Event); // ERROR! a not defined
The .emit() method is a bit more strict than .on/.off(), but still allows for supplying an explicitly-constructed Event.
Here's the EventTarget implementation:
/**
* Used to declare known compile-time events
* for a class that extends `EventTarget` or
* implements `IEventTarget`.
*/
const EventRecord: unique symbol = Symbol("EventRecord");
//
export class EventTarget {
declare [EventRecord]: {};
//
public on<K extends keyof this[typeof EventRecord]>(
type: K,
listener: (e: this[typeof EventRecord][K] extends Event ? this[typeof EventRecord][K] : never) => void | undefined,
options?: boolean | OnEventOptions
): void;
public on(type: string, listener: (e: Event) => void | undefined, options?: boolean | OnEventOptions): void;
public on(type: string, listener: Function, options?: boolean | OnEventOptions): void {
// code
}
//
public off<K extends keyof this[typeof EventRecord]>(
type: K,
listener: (e: this[typeof EventRecord][K] extends Event ? this[typeof EventRecord][K] : never) => void | undefined,
options?: boolean | OffEventOptions
): void;
public off(type: string, listener: (e: Event) => void | undefined, options?: boolean | OffEventOptions): void;
public off(type: string, listener: Function, options?: boolean | OffEventOptions): void {
// code
}
//
public emit<K extends string & keyof this[typeof EventRecord], C extends (new (type: K) => EventResult), EventResult extends this[typeof EventRecord][K]>(type: K, constructor: C): boolean;
public emit<K extends string & keyof this[typeof EventRecord], C extends (new (type: K, options: O) => EventResult), O, EventResult extends this[typeof EventRecord][K]>(type: K, constructor: C, options: O): boolean;
public emit(event: Event): boolean;
public emit(arg1: any, arg2?: any, arg3?: any): boolean {
let event: null | Event = null;
if (arg1 instanceof Event) {
event = arg1;
} else {
event = typeof arg3 !== "undefined" ?
new arg1(arg2, arg3) :
new arg1(arg2);
}
// TODO - emission logic
todo();
}
}
type OnEventOptions = {
capture?: boolean,
};
type OffEventOptions = {
capture?: boolean,
};
Now you get autocompletion and type inference without any significant effort, and can even use generics for events if the case. Like for the other popular approaches, you need to explicitly inherit the supertype's event record, as you saw on the above usage example, however our case looks certainly cleaner.
I was previously investing into a language which would make event descriptions look neat, but I gave up on the compiler engineering field (I'm closing with the tsc API for now). Still, this looks pretty satisfactory to me.
Top comments (0)