DEV Community

Theodor Diaconu
Theodor Diaconu

Posted on • Originally published at bluelibs.com

Async & Type-safe Event Management in JS

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
Enter fullscreen mode Exit fullscreen mode
import { EventManager } from "@bluelibs/core";

const eventManager = new EventManager();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

You could also control this from the event emission:

// don't await emission
eventManager.emit(new SomethingHappenedEvent());

// await and make it blocking
await eventManager.emit(new SomethingHappenedEvent());
Enter fullscreen mode Exit fullscreen mode

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.

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",
  })
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Global Listeners

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

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,
  }
);
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)