TypeScript Event Handlers

My introductory 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: events in React behave more or less exactly as 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 fast-and-loose dynamic language like JavaScript.

But first, React

React event handlers receive synthetic, React-specific events. An onClick handler, for instance, can expect to be passed the corresponding React.MouseEvent:

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?

An investment in TypeScript (or any other static type-checker) pays off in guarantees that the data inside our program are well-formed. Any question about those guarantees 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–a sound type system being one that catches all errors, whether or not they’re possible at runtime–and completeness–a complete system being one that will only report possible errors. The ideal system is sound and complete.

TypeScript’s contributors are smart people that are doubtless well-aware of the point. Yet we have conclusive evidence of TypeScript behaving unsoundly. Nor is this the only case: there are a number of special circumstances the compiler will ignore.

These circumstances are quite deliberate. One of TypeScript’s design goals is to remain a superset of JavaScript. That means allowing most of the behavior that JavaScript allows, which in broad term is 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, I'm RJ! For more learnings about software and management, find me @rjzaworski or sign up for my semi-regular newsletter.

Let’s keep in touch

Send me timely updates on software, product, and process.