EventTarget with TypeScript
- 6/18/2021
- ·
- #typescript
- #howto
The EventTarget
DOM interface is used by a variety of browser
APIs to dispatch events and attach listeners. It isn’t prescriptive about the
types of events that an implementation can send, however, as reflected by
rather general types:
interface EventTarget {
dispatchEvent(e: Event): boolean
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void
removeEventListener(
type: string,
listener: null | EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void
}
In other words, a program interacting with an EventTarget
can dispatch and
listen for pretty much anything (whether the implementation supports it or
not). That’s a reasonable default for JavaScript, but with a type system in the
mix we can make dealing with the EventTarget
a little safer.
Adding TypeScript to an EventTarget
An EventTarget
comes with a dictionary of events. Using TypeScript a simple
dictionary might look something like this:
type ReadyEvent = { readonly type: 'ready' };
type DataEvent = {
readonly type: 'data';
readonly data: any;
};
type MyEvent = ReadyEvent | DataEvent;
Implementing a pure TypeScript event emitter around this
dictionary like this is straightforward enough, but if when interacting with
DOM APIs the dictionary could also fit into a narrower, typesafe EventTarget
.
Here’s a minimal example, omitting some of the option-handling for brevity:
class MyEventTarget extends EventTarget {
public dispatchEvent(e: Event & MyEvent): boolean {
return super.dispatchEvent(e);
}
public dispatch(e: MyEvent): boolean {
return this.dispatchEvent(Object.assign(new Event(e.type), e));
}
public addEventListener<
T extends MyEvent['type'],
E extends MyEvent & { type: T }
>(type: T, listener: ((e: Event & E) => boolean) | null) {
super.addEventListener(type, listener);
}
public removeEventListener(type: MyEvent['type']) {
super.removeEventListener(type, null)
}
}
Except for the nice-but-not-strictly-necessary convenience of the dispatch
helper, this is a faithful (if bare bones) implementation of the EventTarget
interface. By imposing tighter constraints on the base class, however, the type
checker will now ensure that MyEventTarget
is being used appropriately at
compile time.
Consider cases like:
target.addEventListener('ready', function (event) {
// `event.message` is undefined (listener is bound to `ReadyEvent`)
// @ts-expect-error
console.log(event.message);
return false;
});
// doesn't implement `MyEvent`
// @ts-expect-error
target.dispatchEvent(new Event('some-event'));
// the `'foobar'` event isn't defined
// @ts-expect-error
target.dispatch({ type: 'foobar' });
// missing `.data` property
// @ts-expect-error
target.dispatch({ type: 'data' });
That’s a big improvement over the “anything goes” jungle of the underlying DOM API! But a little bit of cleanup can make it even better.
Making it generic
Rather than creating bespoke subclasses of EventTarget
for every purpose,
consider a generic version that just needs an event dictionary plugged in:
class TypedEventTarget<EventDef extends { type: any }> extends EventTarget {
public dispatchEvent(e: Event & EventDef): boolean {
return super.dispatchEvent(e);
}
public dispatch(e: EventDef): boolean {
const event = Object.assign(new Event(e.type), e);
return this.dispatchEvent(event);
}
public addEventListener<
T extends EventDef['type'],
E extends EventDef & { type: T }
>(type: T, listener: ((e: Event & EventDef) => boolean) | null) {
super.addEventListener(type, listener);
}
public removeEventListener(type: EventDef['type']) {
super.removeEventListener(type, null)
}
}
Enlisting this new TypedEventTarget
, the original example is almost trivial to
implement–while still keeping all of the same typesafe goodness:
class MyEventTarget extends TypedEventTarget<MyEvent> {}
const target = new MyEventTarget();
Not too bad. And guaranteed better at runtime!