TypeScript Event Handlers
- 10/17/2018
- ·
- #typescript
- #react
After a recent talk on TypeScript and React, a few minutes of open Q&A led into a lively discussion about typing event handlers in both React and the DOM. The first is fairly straightforward: dealing with events in TypeScript and React goes more or less exactly how you would expect.
TypeScript’s quirky implementation of DOM events, however, leads to an interesting conversation about expectations, tradeoffs, and the challenge of trying to type a dynamic language like JavaScript.
But first, React
React event handlers receive synthetic,
React-specific events. An onClick
callback, for instance, can expect to be
passed a React.MouseEvent
to handle and map to business
logic:
class MyComponent extends React.Component<P, S> {
// See: @types/react/index.d.ts
onClick(e: React.MouseEvent) {
// ...
}
render() {
return (
<button onClick={this.onClick}>
Click me!
</button>
);
}
}
If we inspect the corresponding type declarations, we’ll see that React gives
event handlers like onClick
a fairly specific type:
// @types/react/index.d.ts
interface MouseEvent<T = Element> extends SyntheticEvent<T> {
// ...
}
type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;
interface DOMAttributes<T> {
// ...
onClick?: MouseEventHandler<T>;
}
Since clicking requires a mouse, expecting that onClick
should receive a
MouseEvent
(rather than, say, a KeyboardEvent
) makes sense. Calling
onClick
with anything else will rightly yield a compiler error, and–if we
were only concerned with React–that would be that.
What about the DOM?
Think of React as a “write-once, run anywhere” layer on top of the Document Object Model (DOM). Instead of dealing with quirky browser APIs, developers get to work with a consistent, vendor-independent view of the world. And as anyone who’s done it the other way knows, that’s a pretty amazing thing.
Sometimes, though, we have to cut through React’s friendly abstractions and deal with what lies beneath. Here, event-handling gets a little more interesting. The setup is simple enough:
const onClick = (e: MouseEvent) =>
console.log(`(${e.clientX}, ${e.clientY})`);
window.addEventListener('click', onClick);
But there’s a surprising implementation here that’s worth an extra look.
Aside: Why type at all?
We invest in TypeScript (or any other static type-checker) in return for predictable, well-formed data. Any questions about those data spawns further, uncomfortable questions about the value of our investment. We expect TypeScript to tell us when things break.
I bet you see where this is headed.
Something wicked
Let’s try a little experiment. In addition to clicking the mouse, we’ll now let users “click” with a key.
const onClick = (e: MouseEvent) =>
console.log(`(${e.clientX}, ${e.clientY})`);
window.addEventListener('click', onClick);
window.addEventListener('keydown', onClick);
As one would expect, keydown
will spawn a KeyboardEvent
–not the
MouseEvent
the callback is expecting. And yet this change compiles! It runs,
too, though the console output won’t be particularly useful:
> (undefined, undefined)
The compiler can’t know at compile-time that passing a KeyboardEvent
to
onClick
will be safe–yet for some reason it allows the behavior anyway.
In my Type System?
Type systems are described in terms of soundness and completeness. A sound type system is one that catches all errors, whether or not they’re possible at runtime, while a complete system will save false positives by only reporting on possible errors. Ideally, a type system is both.
TypeScript’s contributors are smart people that certainly get the point. Yet our example is a clear case of unsound behavior. Nor is it the only one: there are a number of other special circumstances that the compiler will deliberately ignore.
These circumstances are quite intentional. One of TypeScript’s design goals is to remain a superset of JavaScript. That means allowing many of the dubious behaviors that masquerade as “idiomatic JavaScript”. What JavaScript allows, TypeScript often does, too. And JavaScript allows a lot.
One of those things is callback reuse: an unusual-but-not-uncommon pattern that TypeScript goes out of its way to accommodate (flowtype, incidentally, does not). Feel like binding mouse events and keyboard events to the same handler? Here’s your rope.
In the spirit of the extensible web, React gets to make its own rules. One of those rules is consistent callback behavior.
Conclusion
Even when TypeScript is playing by JavaScript rules, however, there are limits
to what the compiler will allow. Let’s try adjusting onClick
to expect a
number:
const onClick = (n: number) => { /* ... */ }
window.addEventListener('click', onClick);
This time, we’ll see the expected compiler error:
// Argument of type '(e: number) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject'.
// Type '(e: number) => void' is not assignable to type 'EventListenerObject'.
// Property 'handleEvent' is missing in type '(e: number) => void'.
A KeyboardEvent
where a MouseEvent
should be, whatever. But even when
accommodating browser APIs a number
is a bridge too far. Even unsound cases
have their limits.