Creating type-safe events in Typescript

by Tom Raaff — 5 minutes

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

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);
  };
}

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!

meerdivotion

Cases

Blogs

Event