TypeScript Event Handlers

My recent presentation on TypeScript and React led to 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.

Hey, it's RJ—thanks for reading! If you enjoyed this post, would you be willing to share it on Twitter, Facebook, or LinkedIn?