Table of Contents

I’ve been playing around with TypeScript recently, and enjoying it too. Nearly all of my compile-time type safety desires have been accomodated by the language, and in a rather intuitive and clean way. Today, I’m going to share a little trick I’ve discovered which allows me to do something that I suspect would normally require dependent types.

The Problem

Suppose you want to write a class that emits events. Clients can then install handlers, functions that are notified whenever an event is emitted. Easy enough; in JavaScript, this would look something like the following:

From js1.js, lines 1 through 17
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class EventEmitter {
    constructor() {
        this.handlers = {}
    }

    emit(event) {
        this.handlers[event]?.forEach(h => h());
    }

    addHandler(event, handler) {
        if(!this.handlers[event]) {
            this.handlers[event] = [handler];
        } else {
            this.handlers[event].push(handler);
        }
    }
}

We can even write some code to test that this works (just to ease my nerves):

From js1.js, lines 19 through 23
19
20
21
22
23
const emitter = new EventEmitter();
emitter.addHandler("start", () => console.log("Started!"));
emitter.addHandler("end", () => console.log("Ended!"));
emitter.emit("end");
emitter.emit("start");

As expected, we get:

Ended!
Started!

As you probably guessed, we’re going to build on this problem a little bit. In certain situations, you don’t just care that an event occured; you also care about additional event data. For instance, when a number changes, you may want to know the number’s new value. In JavaScript, this is a trivial change:

From js2.js, lines 1 through 17
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class EventEmitter {
    constructor() {
        this.handlers = {}
    }

    emit(event, value) {
        this.handlers[event]?.forEach(h => h(value));
    }

    addHandler(event, handler) {
        if(!this.handlers[event]) {
            this.handlers[event] = [handler];
        } else {
            this.handlers[event].push(handler);
        }
    }
}

That’s literally it. Once again, let’s ensure that this works by sending two new events: stringChange and numberChange.

From js2.js, lines 19 through 23
19
20
21
22
23
const emitter = new EventEmitter();
emitter.addHandler("numberChange", n => console.log("New number value is: ", n));
emitter.addHandler("stringChange", s => console.log("New string value is: ", s));
emitter.emit("numberChange", 1);
emitter.emit("stringChange", "3");

The result of this code is once again unsurprising:

New number value is:  1
New string value is:  3

But now, how would one go about encoding this in TypeScript? In particular, what is the type of a handler? We could, of course, give each handler the type (value: any) => void. This, however, makes handlers unsafe. We could very easily write:

emitter.addHandler("numberChanged", (value: string) => {
    console.log("String length is", value.length);
});
emitted.emit("numberChanged", 1);

Which would print out:

String length is undefined

No, I don’t like this. TypeScript is supposed to be all about adding type safety to our code, and this is not at all type safe. We could do all sorts of weird things! There is a way, however, to make this use case work.

The Solution

Let me show you what I came up with:

From ts.ts, lines 1 through 19
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class EventEmitter<T> {
    private handlers: { [eventName in keyof T]?: ((value: T[eventName]) => void)[] }

    constructor() {
        this.handlers = {}
    }

    emit<K extends keyof T>(event: K, value: T[K]): void {
        this.handlers[event]?.forEach(h => h(value));
    }

    addHandler<K extends keyof T>(event: K, handler: (value: T[K]) => void): void {
        if(!this.handlers[event]) {
            this.handlers[event] = [handler];
        } else {
            this.handlers[event].push(handler);
        }
    }
}

The important changes are on lines 1, 2, 8, and 12 (highlighted in the above code block). Let’s go through each one of them step-by-step.

Let’s give this a spin with our numberChange/stringChange example from earlier:

From ts.ts, lines 21 through 27
21
22
23
24
25
26
27
const emitter = new EventEmitter<{ numberChange: number, stringChange: string }>();
emitter.addHandler("numberChange", n => console.log("New number value is: ", n));
emitter.addHandler("stringChange", s => console.log("New string value is: ", s));
emitter.emit("numberChange", 1);
emitter.emit("stringChange", "3");
emitter.emit("numberChange", "1");
emitter.emit("stringChange", 3);

The function calls on lines 24 and 25 are correct, but the subsequent two (on lines 26 and 27) are not, as they attempt to emit the opposite type of the one they’re supposed to. And indeed, TypeScript complains about only these two lines:

code/typescript-emitter/ts.ts:26:30 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
26 emitter.emit("numberChange", "1");
                                ~~~
code/typescript-emitter/ts.ts:27:30 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
27 emitter.emit("stringChange", 3);
                                ~
Found 2 errors.

And there you have it! This approach is now also in use in Hydrogen, a lightweight chat client for the Matrix protocol. In particular, check out EventEmitter.ts.