Proposal: type-safe event listeners

In the ReScript ecosystem today we manage event listeners in a type-unsafe way, e.g. https://github.com/rescript-lang/rescript-react/blob/c3017ec4bbce5847c3b2da8d2d536450a3e2fd6d/src/ReactEvent.resi#L182

@bs.get external target: t => {..} = "target"

This allows unsafely getting any property from the event, which may result in a runtime type error.

Or, https://github.com/tinymce/rescript-webapi/blob/558c7d5a3eb533138114a1fc6fe7cdd9a2a33314/src/Webapi/Dom/Webapi__Dom__EventTarget.res#L8

@send external addEventListener: (T.t, string, Dom.event => unit) => unit = ""

This gives us only the abstract type Dom.event, forcing us to unsafely downcast it to the exact event type we need. A mistake here can also result in a runtime type error.

I am proposing a new design for events and listeners, that is completely type-safe. However, it requires additional event types. Here is a rough sketch:

module Event: {
  open Dom

  /** t = target type, e = event type */
  type name<'t, 'e>
  type mouse<'t, 'e> = mouseEvent_like<'e>

  @send
  external addEventListener: (
    eventTarget_like<_> as 't,
    name<'t, 'e>,
    @uncurry 'e => unit,
  ) => unit = "addEventListener"

  @inline("click") let click: name<element_like<_> as 't, mouse<'t, _>>
} = {
  open Dom
  
  type name<'t, 'e> = string
  type mouse<'t, 'e> = mouseEvent_like<'e>

  @send
  external addEventListener: (
    eventTarget_like<_> as 't,
    name<'t, 'e>,
    @uncurry 'e => unit,
  ) => unit = "addEventListener"

  @inline let click = "click"
}

// Test: change to Dom.node for type error

let add = (elem: Dom.element) => {
  open Event
  elem->addEventListener(click, Js.log)
}

The key here is the type given to the value click. This abstract type is given type parameters which encode the type safety rules:

  • The event can target only DOM elements i.e. only elements can handle this event
  • It is specifically a mouse event

The other piece of the puzzle is the type given to addEventListener. Its three parameters are given types that propagate the type safety rules from the event target, to the event handler function itself.

We need to introduce the name<_,_> type here to enforce the safety rules, and also the type mouse<'t, 'e> = mouseEvent_like<'e> and other event type aliases like that to capture the type of the event’s target.

To maintain the rules for all possible events, we need only maintain the list of abstract values that represent the names of the events, like click. To circumvent the safety rules, we just need to use Obj.magic or the %identity external (of course, hopefully with very good reason).

I would appreciate any feedback on this.

21 Likes

Only just saw you mention this in A good `createElement`? - #2 by yawaramin and I think it’s really cool!

Even though I’ve written a fair bit of ReScript now, the way you’re writing type-propagation is a bit new to me (but seems useful in other places!), do you have any resources on how I’d come up with something like that myself?

Additionally are element_like and eventTarget_like omitted from the example or are they already defined elsewhere in ReScript?

Hi, that’s good to hear. It’s a bit difficult to come up with materials for techniques like this because it combines several different tricks. I guess you could look for materials on abstract types, phantom types, and type inference to understand how the parts fit together.

Re: element_like etc., those are in ReScript’s Dom module: Dom | ReScript API

1 Like