Skip to main content

Async & Type-safe Event Management

· 4 min read

In this article, we'll explore the best ways to handle events with TypeScript or vanilla JS inside any runtime environment. (Browser, Node and Deno)

You can test this library very quickly here, and find the full documentation here.

What makes a good event manager from my perspective:

  1. Asynchronous by default
  2. Type-safe by default
  3. Collision-proof design (events with the same name)
  4. Ability to add, remove listeners
  5. Ability to configure the order of triggering of handlers
  6. Ability to listen globally to all events or group of events.
  7. Isomorphic (works independent of JavaScript runtime environment)
  8. Small in size

Install

npm i -S @bluelibs/core
import { EventManager } from "@bluelibs/core";
const eventManager = new EventManager();

It weighs 8.8kB minified and gzipped, but in reality it's 5.28 kB as 30% is reflect-metadata, which is going to be stripped-out most likely by your bundler on the frontend.

Asynchronous

The very definition of handling events is asynchronous, and now that the world is moving to Promise-based applications, we believe that all event handlers should be async by default.

import { EventManager } from "@bluelibs/core";
class SomethingHappenedEvent extends Event {}
async function init() {  const eventManager = new EventManager();
  eventManager.addListener(SomethingHappenedEvent, async (e) => {    // Type-safety for e, e is an instance of SomethingHappenedEvent, automatic type inference    console.log("I have been emitted");  });
  // This will await execution all events  await eventManager.emit(new SomethingHappenedEvent());}
init();

You can also have events that perform things in the background:

eventManager.addListener(SomethingHappenedEvent, async (e) => {  emailService.sendEmail(); // simply omit "await" for your async service calls
  // Another approach:
  return new Promise(async (resolve, reject) => {    resolve();
    // Do sync stuff here  });});

You could also control this from the event emission:

// don't await emissioneventManager.emit(new SomethingHappenedEvent());
// await and make it blockingawait eventManager.emit(new SomethingHappenedEvent());

This way you benefit of both worlds, giving you ability to control how listeners behave and also giving you the ability to fire-and-forget the event emission too.

note

Do keep in mind that your handlers can still be non-async and be run in sync, awaiting a non-async function just means it's executing it.

Type-safe

Events can only be classes, we no longer trust strings, they can collide, they are hard to have them type safe.

If you're using vanilla JavaScript, you won't benefit of autocompletion for this part.

class UserAddedEvent extends Event<{  userId: string;}> {  async validate() {    // Optional runtime validation before it's dispatched to all listeners  }}
eventManager.emit(  new UserAddedEvent({    userId: "XXX",  }));

Management & Order

I'll just let the code do the talking:

const handler = async function empty(e: UserAddedEvent) {};
eventManager.addListener(UserAddedEvent, handler, {  // Make it run first  order: -1000, // Use -Infinity if you're feeling courageous  filter: (e) => e.data.userId !== "ADMIN", // The listener will only run if the filter returns true.});
eventManager.removeListener(UserAddedEvent);

Global Listeners

manager.addGlobalListener(async (e: Event) => {  // Custom logging maybe?});// and it's subsequent: removeGlobalListener

You could fine-grain this nicely, and listen to groups of events:

class SecurityEvent extends Event {}class UserNotAuthorized extends SecurityEvent {}class UserHackingAttemptEvent extends SecurityEvent {}
manager.addGlobalListener(  async (e: SecurityEvent) => {    // Send an email or do something specific  },  {    filter: (e) => e instanceof SecurityEvent,  });

Serializable

To see a fully working example on how we can approach serialization check this out: https://stackblitz.com/edit/typescript-jg9osn?file=serializable.ts

You can easily benefit of storing these events and running them later by employing some simple tacticts for the SerializableEvent class, and fine-tune it to suit your needs.

Timeouts

As you know, the listener class has all the control, if for example, you want to set a timeout (takes too much time to execute, and is blocking others), then you could implement the following strategy: https://stackblitz.com/edit/typescript-jg9osn?file=timebound.ts

The important part here is that the listener knows how to control the flow, and this can give you super-powers when it comes to controlling async events.

Pluggable in your workflow

This can be easily integrated in your workflow and existing web application, on both frontend, backend, NextJS or React Native apps.

You can test this library very quickly here, and find the full documentation here.

If you enjoyed this short article or library, please consider sharing the love with a star on GitHub!