Creating type-safe events in Typescript
Creating custom events in TS (or JS) is very easy. Creating an eventbus is also quite easy (see this excellent article). But typing your events is not so straightforward. How do you make sure that you bind a specific datatype to a specific event name ( which is just a string)?
Let's use an imaginary bookstore as an example. There are two types of events: 'customer created' and 'order created'. Both of these events contain data. Respectively a Customer and an Order.
type Customer = {
id: string;
name: string;
};
type Order = {
book: string;
price: number;
};
How do we make sure that when we want to publish a 'customer created' event, we'll only be able to add a Customer as a payload?
Let's skip right to the solution. Here it is:
const eventBus = new Comment("event-bus");
type Customer = {
id: string;
name: string;
};
type Order = {
book: string;
price: number;
};
type EventsDefinition = {
CUSTOMER_CREATED: Customer;
ORDER_CREATED: Order;
ORDER_SHIPPED: void;
};
type BookstoreEvents = keyof EventsDefinition;
function publish<T extends BookstoreEvents>(
eventName: T,
payload?: EventsDefinition[T],
): void {
const event = payload
? new CustomEvent(eventName, { detail: payload })
: new CustomEvent(eventName);
eventBus.dispatchEvent(event);
}
type Unsubscribe = () => void;
function isCustomEvent(event: Event): event is CustomEvent {
return "detail" in event;
}
function subscribe<T extends BookstoreEvents>(
eventName: T,
handlerFn: (payload: EventsDefinition[T]) => void,
): Unsubscribe {
const eventHandler = (event: Event) => {
if (isCustomEvent(event)) {
const eventPayload: EventsDefinition[T] = event.detail;
handlerFn(eventPayload);
}
};
eventBus.addEventListener(eventName, eventHandler);
return () => {
eventBus.removeEventListener(eventName, eventHandler);
};
}
// example usage with type safety
subscribe("CUSTOMER_CREATED", (customer: Customer) => {
console.log("customer created", customer);
});
subscribe("ORDER_CREATED", (order: Order) => {
console.log("order created", order);
});
subscribe("ORDER_SHIPPED", () => console.log("order shipped"));
publish("CUSTOMER_CREATED", { id: "23498783", name: "Dalinar Kholin" });
publish("ORDER_CREATED", { book: "The way of kings", price: 23.99 });
publish("ORDER_SHIPPED");
So, let's break down what is going on here. First, we create an eventBus. Using
Comment
is probably surprising. You can read more about the eventbus in this
excellent
article
I mentioned earlier.
Here we define our events.
type EventsDefinition = {
CUSTOMER_CREATED: Customer;
ORDER_CREATED: Order;
};
type BookstoreEvents = keyof EventsDefinition;
The EventsDefinition object connects the name of our event to the type of the payload. We want our CUSTOMER_CREATED event to have a payload of type Customer.
The BookstoreEvents type simply takes the keys of the EventDefinition. So, in
this case, the type of BookstoreEvents is equal to
'CUSTOMER_CREATED' | 'ORDER_CREATED'
. But with an extra benefit: if we want to
add new events, we only have to expand our EventsDefinition type.
Publish function
function publish<T extends BookstoreEvents>(
eventName: T,
payload?: EventsDefinition[T],
): void {
const event = payload
? new CustomEvent(eventName, { detail: payload })
: new CustomEvent(eventName);
eventBus.dispatchEvent(event);
}
In short: our publish function is a wrapper around the native dispatchEvent()
function. I called it publish, because that way, you avoid name clashes and
publish and subscribe are common ways to name this behaviour. The Typing is the
interesting part.
When first writing this, I thought it made sense to write the function signature
like this: function publish(eventName: BookstoreEvents, payload?: ...)
But as
you can see, this signature lacks a connection between the event name and its
desired type. The solution is to introduce a generic.
Unfortunately you can't say
function publish<BookstoreEvents>(eventName: BookstoreEvents, payload?: EventsDefinition[BookstoreEvents])
...
You'll get this error:
Type 'BookstoreEvents' cannot be used to index type 'EventsDefinition'
. But
luckily, <T extends BookstoreEvents>
is practically the same as
<BookstoreEvents>
and this will NOT throw the error. (Don't ask me why
though... If you know why this works, let me know...!)
Subscribe function
```typescript type Unsubscribe = () => void;
function isCustomEvent(event: Event): event is CustomEvent { return "detail" in event; }
function subscribe
Here, the typing in the function signature works exactly the same as the publish function. In essence, we add an eventListener and return a function that removes the added event listener. I've chosen for the approach to just pass the event payload instead of the complete event. We need the type-guard to be able to type the event correctly.
Usage
Usage of this event bus is now quite straightforward. Here's an example:
subscribe("CUSTOMER_CREATED", (customer: Customer) => {
console.log("customer created", customer);
});
subscribe("ORDER_CREATED", (order: Order) => {
console.log("order created", order);
});
publish("CUSTOMER_CREATED", { id: "23498783", name: "Dalinar Kholin" });
publish("ORDER_CREATED", { book: "The way of kings", price: 23.99 });
And this is actually type safe. It is now impossible to combine 'ORDER_CREATED' with anything other than an Order payload.
Without a payload
But what if you want to send out an event without a payload? In that case, you
still need to register the event in the EventsDefinition type. The trick is to
give the event a type of void
. Like this:
type EventsDefinition = {
CUSTOMER_CREATED: Customer;
ORDER_CREATED: Order;
ORDER_SHIPPED: void;
};
subscribe("ORDER_SHIPPED", () => console.log("order shipped"));
publish("ORDER_SHIPPED");
This actually works and will throw compiler errors whenever you try to add a payload to either your subscribe handler function or your publish. You should try playing around with this code example!
Happy coding!