Exploring Typed Events in TypeScript | by Jason Sturges | Feb, 2022

Dispatch and handle events with clarity using generic types

Jason Sturges
Photo by Sam Manns on Unsplash

Within my apps are decoupled logic, enabling me to compose controllers together with views against a container or context object.

As these controllers do not have awareness of each other, events are dispatched against a context object.

In larger apps, it becomes onerous to remember what properties each event handler requires — I don’t want to continually inspect the event handler to recall which properties to dispatch.

This pattern of dispatching events gives me that information:

public dispatch<T>(type: string, data: T): void {
}

This enables me to specify an action type and payload of data.

For example, let’s use EventEmitter3 as the event mechanism on the context object. Our context object will extend this emitter and provide a dispatch() implementation:

import EventEmitter from "eventemitter3";

class Context extends EventEmitter {
public dispatch<T>(type: string, data: T) {
this.emit(type, data);
}
}

Let’s try dispatching some events.

Let’s start with something simple — an alert system that reports a string message at different log levels.

First, we need the types of events that can be dispatched. This will be standard info, error, warning, and success as types defined in an enumeration:

enum AlertEvent {
ERROR = "AlertEvent:ERROR",
INFO = "AlertEvent:INFO",
SUCCESS = "AlertEvent:SUCCESS",
WARNING = "AlertEvent:WARNING",
}

Per data, the payload will simply be a string message

type AlertEventOptions = {
readonly message: string;
};

To dispatch the event, pass the AlertEventOptions As the generic function type to define the payload of data we’ll be passing. Then, use the AlertEvent enumeration to define the type of event.

context.dispatch<AlertEventOptions>(AlertEvent.INFO, {
message: "This is some info",
});

context.dispatch<AlertEventOptions>(AlertEvent.ERROR, {
message: "This is an error",
});

To handle the event, again use the AlertEventOptions as the event type:

const onAlertInfo = (event: AlertEventOptions): void => {
console.log(event.message);
};

context.on(AlertEvent.INFO, onAlertInfo);

This enables the IDE to intelligently understand the payload of data:

Let’s say this app is a graphical editing app with different tools the user can change between, such as:

  • Drawing Tool
  • Grab Tool for zoom / panning
  • Select Tool

Let’s define those as a ViewMode enumeration:

enum ViewMode {
DRAW = "ViewMode:DRAW",
SELECT = "ViewMode:SELECT",
GRAB = "ViewMode:GRAB",
}

When the user changes view modes, we’ll dispatch a change event:

enum ViewModeEvent {
CHANGE = "ViewModeEvent:CHANGE",
}

Finally, our data payload will consist of the new mode:

type ViewModeOptions = {
readonly mode: ViewMode;
};

To dispatch, pass the ViewModeOptions with ViewModeEvent.CHANGE and include the ViewMode in data payload:

context.dispatch<ViewModeOptions>(ViewModeEvent.CHANGE, {
mode: ViewMode.SELECT,
});

To handle the event, listen for ViewModeEvents as:

const onViewModeChange = (event: ViewModeOptions): void => {
switch (event.mode) {
case ViewMode.SELECT:
// ...
break;
case ViewMode.DRAW:
break;
case ViewMode.GRAB:
break;
}
};

context.on(ViewModeEvent.CHANGE, onViewModeChange);

Once again, the IDE masterfully resolves types:

If your data payload contains several properties without the intention of including them all, perhaps implement it as a partial:

export type LinkOptions = {
readonly href: string;
readonly target: string;
};
const onNavigate = (event: Partial<LinkOptions>): void => {
// ...
};

However, this might reflect an overly complex event — consider breaking it down for clarity.

As with anything in programming, there are alternative approaches especially when implementing dependency injection and inversion of control patterns.

Type aliases provide delegates, in which a predicate function could handle actions — something I’ll explore in an upcoming post.

Leave a Comment