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:
|
|
We can even write some code to test that this works (just to ease my nerves):
|
|
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:
|
|
That’s literally it. Once again, let’s ensure that this works by sending two new events:
stringChange
and numberChange
.
|
|
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:
|
|
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.
-
Line 1: Parameterize the
EventEmitter
by some typeT
. We will use this typeT
to specify the exact kind of events that ourEventEmitter
will be able to emit and handle. Specifically, this type will be in the form{ event: EventValueType }
. For example, for amouseClick
event, we may write{ mouseClick: { x: number, y: number }}
. -
Line 2: Add a proper type to
handlers
. This requires several ingredients of its own:- We use index signatures
to limit the possible names to which handlers can be assigned. We limit these names
to the keys of our type
T
; in the preceding example,keyof T
would be"mouseClick"
. - We also limit the values:
T[eventName]
retrieves the type of the value associated with keyeventName
. In the mouse example, this type would be{ x: number, y: number }
. We require that a key can only be associated with an array of functions to void, each of which acceptsT[K]
as first argument. This is precisely what we want; for example,mouseClick
would map to an array of functions that accept the mouse click location.
- We use index signatures
to limit the possible names to which handlers can be assigned. We limit these names
to the keys of our type
-
Line 8: We restrict the type of
emit
to only accept values that correspond to the keys of the typeT
. We can’t simply writeevent: keyof T
, because this would not give us enough information to retrieve the type ofvalue
: ifevent
is justkeyof T
, thenvalue
can be any of the values ofT
. Instead, we use generics; this way, when the function is called with"mouseClick"
, the type ofK
is inferred to also be"mouseClick"
, which gives TypeScript enough information to narrow the type ofvalue
. - Line 12: We use the exact same trick here as we did on line 8.
Let’s give this a spin with our numberChange
/stringChange
example from earlier:
|
|
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
.