Mastodon Icon GitHub Icon LinkedIn Icon RSS Icon

A simple Event System in TypeScript

Events are an intuitive way to model the execution flows of applications when several modules, each one with complex lifecycles, need to synchronize with each other. This is especially true if the application involves frequent user-initiated inputs.

The application I maintain at work falls exactly in this space (it is a backend system for managing interactive conversational bots). Yes, you could happily use a package for that (here a good one), but I always prefer to understand how things work under the hood.

For this reason, in this article I’ll present you the implementation of a very simple event system for TypeScript.

Simple Synchronous Signals

At the very basic, an Event is just a simple object to which someone can subscribe and receive a signal when the event is triggered. We can imagine a class with three basic methods:

  • on to subscribe to the event/signal.
  • off to unsubscribe
  • trigger used to trigger the event (and notify the subscribers).

I will call this class Signal (I will not use Event because Event frequently name-clash in TypeScript).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export class Signal<S, T> {
    private handlers: Array<(source: S, data: T) => void> = [];

    public on(handler: (source: S, data: T) => void): void {
        this.handlers.push(handler);
    }

    public off(handler: (source: S, data: T) => void): void {
        this.handlers = this.handlers.filter(h => h !== handler);
    }

    public trigger(source: S, data: T): void {
        // Duplicate the array to avoid side effects during iteration.
        this.handlers.slice(0).forEach(h => h(source, data));
    }
}

Very simple. Does it work? It is easy to check. Let’s create a class Dog with BarkEvent event.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Dog {
    private readonly onBark = new Signal<Dog, string>();

    constructor(readonly name: string) {}

    public get BarkEvent(): Signal<Dog, string> {
        return this.onBark;
    }

    public sayWoof() {
        this.onBark.trigger(this, "WOOF!");
    }
}

Now, let’s create a class to receive the event. For instance:

1
2
3
4
5
6
7
8
class DogListener {
    constructor(dog: Dog) {
        let dogBarkHandler = (s: Dog, bark: string) => {
            console.log(`Dog ${dog.name} barked: ${bark}`);
        }
        dog.BarkEvent.on(dogBarkHandler);
    }
}

And finally let’s run everything:

1
2
3
4
5
const apollo = new Dog("Apollo");
const apolloListener = new DogListener(apollo);

apollo.sayWoof()
// apolloListener will print -> "Listener: Dog "Apollo" barked: WOOF!" 

Live Example

A slightly improved example

That’s cool. But there is a problem.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class EvilDogListener {

    constructor(dog: Dog) {
        let dogBarkHandler = (s: Dog, bark: string) => {
            console.log(`Listener: Dog "${dog.name}" barked: ${bark}`);
        }
        dog.BarkEvent.on(dogBarkHandler);

        // NOW I CAN MAKE THE DOG BARK!
        dog.BarkEvent.trigger(dog, "FAKE WOOF!")
    }
}

Ops. Now the listener can make the dog bark! This is a clear violation of what an event subscriber can do. Luckily, we can easily fix this.

First, let’s create a public interface for our signal. This will only include the methods that can be used by the listeners. No triggers.

1
2
3
4
interface ISignal<S,T> {
    on(handler: (source: S, data: T) => void): void;
    off(handler: (source: S, data: T) => void): void;
}

Now we can edit our Signal class. We will implement this class and provide an expose method:

1
2
3
4
5
6
7
class Signal<S,T> implements ISignal<S,T> {
    // as before
    
    public expose(): ISignal<S,T> {
        return this
    }
}

And update the event on Dog in this way:

1
2
3
    public get BarkEvent(): ISignal<Dog, string> {
        return this.onBark.expose();
    }

Because, expose only returns an ISignal, the listener would be unable to trigger the event.1

Now the EvilDogListner will not compile, and we will get the error: Property 'trigger' does not exist on type 'ISignal<Dog, string>'.

Live Example

Simple Asynchronous Signals

The Signal we implemented before can handle correctly synchronous events: when the event is triggered, we run the subscribed function, and it immediately returns. Blocking the execution until every event handler is executed. But what if we want to subscribe an async function? After all, TypeScript and JavaScript are heavily asynchronous and event handlers may do a lot of asynchronous stuff (e.g., doing API requests, updating databases and so on).

The upgrade is simple. We will change the handler function signature from (source: S, data: T) => void to (source: S, data: T) => Promise<void>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AsyncSignal<S, T> implements IAsyncSignal<S,T> {
    private handlers: Array<(source: S, data: T) => Promise<void>> = [];

    public on(handler: (source: S, data: T) => Promise<void>): void {
        this.handlers.push(handler);
    }

    public off(handler: (source: S, data: T) => Promise<void>): void {
        this.handlers = this.handlers.filter(h => h !== handler);
    }

    public async trigger(source: S, data: T): Promise<void> {
        this.handlers.slice(0).map(h => h(source, data));
    }

    public async triggerAwait(source: S, data: T): Promise<void> {
        const promises = this.handlers.slice(0).map(h => h(source, data));
        await Promise.all(promises);
    }
    
    public expose(): IAsyncSignal<S,T> {
        return this
    }
}

The trigger method is now async and, when called, it will immediately return without waiting for the completion of the several handlers.

In some cases, we may want to trigger an event and wait for all the subscribers to complete like in the synchronous case. For this reason, we will also add a triggerAwait version of trigger.

Asynchronous Signals with Binding

I will now show you a variation of AsyncSignal that is slightly more robust (depending on the use cases). With the previous version, in fact, it is possible to double-subscribe to an event.

A better solution is to not attach a handler as a function, instead, let’s use an object containing more information on the binding.

1
2
3
4
interface SignalBindingAsync<S, T> {
    listener?: string;
    handler: (source: S, data: T) => Promise<void>;
}

We can put there all the info we need to tune the binding as we need. In a simple version, we can just add an identifier for the listener so that we can guarantee to have just one handler for each listener (this is useful if the application you are working with can create a lot of dynamic listeners).

The extended implementation is easy. In the example below I changed on/off with bind/unbind, just to have a clearer distinction, but you can keep on/off if you prefer. Also, in the implementation I used lodash to simplify the contains function, but you can avoid that with some extra code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class AsyncSignal<S, T> implements IAsyncSignal<S,T> {
    private handlers: Array<SignalBindingAsync<S, T>> = [];

    public bind(
        listener: string,
        handler: (source: S, data: T) => Promise<void>,
    ): void {
        if (this.contains(listener)) {
            this.unbind(listener);
        }
        this.handlers.push({ listener, handler });
    }

    public unbind(listener: string): void {
        this.handlers = this.handlers.filter(h => h.listener !== listener);
    }

    public async trigger(source: S, data: T): Promise<void> {
        // Duplicate the array to avoid side effects during iteration.
        this.handlers.slice(0).map(h => h.handler(source, data));
    }

    public async triggerAwait(source: S, data: T): Promise<void> {
        // Duplicate the array to avoid side effects during iteration.
        const promises = this.handlers.slice(0).map(h => h.handler(source, data));
        await Promise.all(promises);
    }

    public contains(listener: string): boolean {
        return _.some(this.handlers, h => h.listener === listener);
    }
    
    public expose(): IAsyncSignal<S,T> {
        return this
    }
}

Conclusion

I hope this is useful for someone. I know there is probably a better solution for that, but this simple event system is serving me well so far. If you think it can be improved in any way, just let me know!

All the code examples in this page are released under the MIT license. You can find the final example at this link.


Photo by Will Araya on Unsplash


  1. Of course, they could cast it to Signal… but this will stop not self-damaging coders. ↩︎

comments powered by Disqus