Let's build a Pub-Sub in TypeScript

A PubSub is an object that can subscribe to listeners and emit events.

One of the many possible interfaces can look like this.

const pubsub = createPubSub()

pubsub.on("MY_EVENT", () => {
  console.log("Hello, pubsub!")
})

pubsub.emit("MY_EVENT");
// => Hello, pubsub!

Events might also go with some payload specific to the event type.

For example,

pubsub.on("USER_LOGGED_IN", (name: string) => {
  console.log(`Hello, ${name}!`)
})

First attempt

Let's first implement a first naive version with no types.

function createPubSub() {
  const eventMap = {} as Record<"string", Set<(...args: any[]) => void>>;

  return {
    on: (event: string, callback: (...args: any[]) => void) => {
      if (!eventMap[event]) {
        // create a new set
        eventMap[event] = new Set();
      }

      eventMap[event].add(callback);
    },

    off: (event: string, callback: (...args: any[]) => void) => {
      if (!eventMap[event]) {
        return;
      }

      eventMap[event].delete(callback);
    },

    emit: (event: string, ...args: any[]) => {
      if (!eventMap[event]) {
        return;
      }

      eventMap[event].forEach((cb: any) => cb(...args));
    },
  };
}

Note we are using Set for callbacks so we don't have to deal with duplicates.

While this works, it's not ideal. The most obvious problem is that we can easily emit events that don't exist.

Typing events

Consider the following type:

// no error
pubsub.on("USER_SIGND_IN", (name: string) => {
  console.log("...")
})

pubsub.emit("USER_SIGNED_IN", "john22@example.com")

How to make TypeScript help us here?

We can define a set of possible events upfront using string type union.

function createPubSub<T extends string>() {
  const eventMap = {} as Record<T, Set<(...args: any[]) => void>>;

  return {
    on: (event: T, callback: (...args: any[]) => void) => {
      if (!eventMap[event]) {
        // create a new set
        eventMap[event] = new Set();
      }

      eventMap[event].add(callback);
    },

    off: (event: T, callback: (...args: any[]) => void) => {
      if (!eventMap[event]) {
        return;
      }

      eventMap[event].delete(callback);
    },

    emit: (event: T, ...args: any[]) => {
      if (!eventMap[event]) {
        return;
      }

      eventMap[event].forEach((cb: any) => cb(...args));
    },
  };
}

We replaced the event string type with a more specific T extends string. Now when we create a pub-sub we can specify the event type.

const pubsub = createPubSub<'USER_SIGNED_IN' | 'USER_LOGGED_OUT'>()

pubsub.on("USER_SIGND_IN", (name: string) => {
  console.log("...")
})
// (!) Argument of type '"USER_LOGGD_IN"' is not assignable to
// (!) parameter of type '"USER_LOGGED_IN" | "USER_SIGNED_OUT"'

Much better! TypeScript saved us a lot of time debugging a weird issue in runtime.

Typing callbacks

Our events are typed now. But our callbacks are not.

Even though the emitted event goes with its own set of parameters, nothing prevents us from using it with a wrong callback.

// USER_SIGNED_IN goes with an email
pubsub.emit("USER_SIGNED_IN", "john22@example.com");
pubsub.on("USER_SIGNED_IN", (id: number) => {
  // shouldn't work: event goes with email, not id
});

// USER_LOGGED_OUT goes without payload
pubsub.emit("USER_LOGGED_OUT");
pubsub.on("USER_LOGGED_OUT", (email: string) => {
  // shouldn't work: no payload with this event at all
});

Since each event is uniquely associated with a parameter list, we can solve it by making our T type a record.

function createPubSub<T extends Record<string, (...args: any[]) => void>>() {
  const eventMap = {} as Record<keyof T, Set<(...args: any[]) => void>>;

  return {
    emit: <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {
      (eventMap[event] ?? []).forEach((cb) => cb(...args));
    },

    on: <K extends keyof T>(event: K, callback: T[K]) => {
      if (!eventMap[event]) {
        eventMap[event] = new Set();
      }

      eventMap[event].add(callback);
    },

    off: <K extends keyof T>(event: K, callback: T[K]) => {
      if (!eventMap[event]) {
        return;
      }

      eventMap[event].delete(callback);
    },
  };
}

It's a bit intense so let's first see how to use it, and then we'll explain how it works.

Now we can easily specify callback types.

const pubSub = createPubSub<{
  USER_SIGNED_IN: (email: string) => void;
  USER_LOGGED_OUT: () => void;
}>();

pubSub.on("USER_SIGNED_IN", (id: number) => {
  // ...
})
// (!) Type 'string' is not assignable to type 'number'

pubSub.on("USER_LOGGED_OUT", (email: string) => {
  // ...
}
// (!) Argument of type '(email: string) => void' is not
// (!) assignable to parameter of type '() => void'.

And even on the emitter side, we are covered.

pubSub.emit("USER_SIGNED_IN", 123)
// (!) Argument of type 'number' is not assignable
// (!) to parameter of type 'string'.

OK, now let's take a closer look at the emit declaration again:

emit: <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {
    (eventMap[event] ?? []).forEach((cb) => cb(...args));
},

Assuming the T type is:

{
  USER_SIGNED_IN: (email: string) => void;
  USER_LOGGED_OUT: () => void;
}

keyof T gives us 'USER_SIGNED_IN' | 'USER_LOGGED_OUT' - pretty much the same type we used for events in the previous version.

Then K extends keyof T means that K is either 'USER_SIGNED_IN' or 'USER_LOGGED_OUT'.

Next, we specify parameters typed as ...args: Parameters<T[K]>.

T[K] gives us the callback type associated with this event.

Parameters is one of the handy built-in utility types. It takes a function and returns the type of parameters.

Hope it's clear now how it ensures the type safety both for events and the callbacks.