How To Write A Proper ReScript Binding

I have been writing ReScript for a number of years at Plow. Writing ReScript has been an enjoyable experience even at one point we never had a single runtime error for close to two years. That is really amazing compared to the JavaScript experience. I even use ReScript for my SaaS - Stencil.

If you have been writing ReScript for a while, you definitely had came across the need to use third party JavaScript libraries. There's no other way around this besides writing a binding to the JavaScript library.

Depending on how you write the bindings, it can either be good for simple use or it can also be awkward as your bindings and code base grow. This is something that we want to avoid from happening. My years of experience writing ReScript leads me to notice that there are proper ways to write bindings that will scale well with your code as your code and binding grow. At least something that has proven to work for our need at Plow.

Generally there are two types of bindings,

  1. Bindings to DOM API and third party libraries.
  2. Binding to third party ReactJS components - ReScript has official support for React with ReScriptReact. (not covered here)

Zero Cost

ReScript bindings are "zero-cost". That means the output doesn't contain the bindings code. So the performance is the same as native JavaScript.

Binding Structure

Let's see the basic structure of any binding. I won't be going into details here as you can read it from the official docs and chances are if you're reading this, you should already be proficient with at least writing basic bindings.

Example

@get external locationSearch: location => string = "search"

Breakdown

<decorator> external <fn_name>: <fn_signature> = "<fn_name_in_js>"

Decorators

Handling Object

When interacting with either the DOM API or external libraries, you'd definitely need access to the object returned by the API or functions from external libraries.

There are generally two ways to handle this.

Assuming we have an object in JS-land that looks like this, how do we model this in ReScript?

var mouseEvent = {
    "clientX": 10,
    "clientY: 219,
    "target": {
        "clientHeight": 100,
        "clientWidth": 200
    }
}

Method 1: Use JS Object

Not to be confused with record despite both have the same representation in JS-land.

You would model each object as a JS object with each field matching the object coming from the JS-land. Everything needs to match exactly.

type target = {
    "clientHeight": int,
    "clientWidth": int
}

type mouseEvent = {
    "clientX": int,
    "clientY": int,
    "target": target
}

While this approach is straight forward to implement, it can be more prone to error. Take the following code for an example.

@get external getMouseEvent: unit => mouseEvent = "mouseEvent"

let evt = getMouseEvent()

let clientX = evt["clientX"] // returns something

What if for some reason that the field clientX does not exist? From the compiler point of view, this type machinery is still sound because we are telling the compiler that mouseEvent object has clientX field.

However, running this code will throw a runtime error. Not good!

Method 2: Use Abstract Type (preferable)

Another approach is to make use of abstract type in ReScript. I have seen this approach in various bindings.

module Target {
    type t

    @get external getClientHeight: t => int = "clientHeight"
    @get external getClientWidth: t => int = "clientWidth"
}

module MouseEvent {
    type t

    @get external getClientX: t => int = "clientX"
    @get external getClientY: t => int = "clientY"
    @get external getTarget: t => Target.t = "target"
}

@get external getMouseEvent: unit => MouseEvent.t = "mouseEvent"

let evt = getMouseEvent()

let clientX = MouseEvent.getClientX(evt) // returns something

If we are unsure about the existence of clientX field, we can reflect that assumption in our type signature.

@get @return(nullable) external getClientX: t => option<int> = "clientX"


let clientX = MouseEvent.getClientX(evt) // returns None if the field doesn't exist

JS Object vs Abstract Type

The question is which one to use?

  1. Use JS Object for simple case and when all fields are available
  2. Abstract Type method is much more flexible

I would usually just go with abstract type since it's really not much work compared to the other approach.

Passing in Object

We have talked about handling receiving object from JS-land previously, now let's talk about sending object to JS-land.

The most frequent use case is passing in options to a JavaScript function and often time the options can vary a lot.

The solution to this is simply to use polymorphic type variable.

@send external makeWithOptions: (t, 'opts) => t = "make"

If you know the shape of the object that needs to be passed in, you can model it with JS object.

type t

type options = {
  "enabled": bool
}

@val external makeWithOptions: (options) => t = "make"


// Usage
let obj = makeWithOptions({"enabled: true"})

// Compile error
// let obj = makeWithOptions({"enabled: true", "color": "#fff000"})

The above compile error is caused by the extra option that we added. It is no longer the same type.

However, we can take advantage of object's structural typing. Remember that in ReScript, object is structural, and record is nominal. By default, object is closed type. We can use an open object instead.

type options<'a> = {
  ..
  "enabled": bool
} as 'a

@val external makeWithOptions: (options<'a>) => t = "make"

or written inline as,

@val external makeWithOptions: {..enabled: bool} => t = "make"

// Usage
let obj = makeWithOptions({"enabled: true"})

// No longer a compile error
let obj = makeWithOptions({"enabled: true", "color": "#fff000"})

Open type is telling the compiler that this type may contain any other fields but it must contain the  enabled field.

Note, soon @obj allows you to do this.

@obj
type r = {
  x : int ,
  y : option <int>
}

let v0 = { x : 3 }
let v1 = { x : 3 , y : None}

with a very clean JS output,

var v0 = { x : 3 }
var v1 = { x : 3 }

More information can be found here https://forum.rescript-lang.org/t/rfc-more-general-type-checking-for-structural-typings/1485/50

Inheritance

Now let's talk about inheritance. JS is OOP (I know some claim that it can be FP 😝). Let's pretend that it is strictly OOP for now. While ReScript is functional where it doesn't have support for inheritance.

How can we emulate this? You definitely don't want to be copying and pasting the same fields all over your types.

Assume the following example where you would have both mouse event and touch event. They both inherit from event base class.

var mouseEvent = {
    "clientX": 10,
    "clientY: 219,
    "target": {
        "clientHeight": 100,
        "clientWidth": 200
    }
}

var touchEvent = {
    "clientX": 10,
    "clientY: 219,
    "target": {
        "clientHeight": 100,
        "clientWidth": 200
    }
}

How can we model the relationship in Rescript when Rescript has no notion of inheritance?

Subtyping and Phantom Type

We can simulate inheritance with subtyping and phantom type but it's only limited to single inheritance.

module Target = {
  type t
}

module Event = {
  type event_like<'a>

  @get external target: event_like<'a> => Target.t = "target"
}

module MouseEvent = {
  type tag
  type t = Event.event_like<tag>

  @send external make: unit => t = "make"
}

module TouchEvent = {
  type tag
  type t = Event.event_like<tag>

  @send external make: unit => t = "make"
}

let mouseEvent = MouseEvent.make()
let touchEvent = MouseEvent.make()

let mouseTarget = Event.target(mouseEvent)
let touchTarget = Event.target(touchEvent)

Subtype allows use to define a relationship between types. event_like<'a> is more general than event_like<tag> i.e. event_like<tag> is a subtype of event_like<'a>. Due to this,event_like<tag> will work whenever event_like<'a> is expected.

This is really nice but it is still limited. For example, MouseEvent.target(mouseEvent) won't work. Why? There's no target function being inherited anywhere. You need to define the function manually - something that we want to avoid at the first place because of code redundancy.

Is there a better way?

Implementation Inheritance

Here's where a technique called implementation inheritance is useful. I stole it from https://github.com/tinymce/rescript-webapi

module Target = {
  type t
}

module MakeEvent = (
  Type: {
    type t
  },
) => {
  @get external target: Type.t => Target.t = "target"
}

module Event = {
  type event_like<'a>

  @get external target: event_like<'a> => Target.t = "target"
}

module MouseEvent = {
  type tag
  type t = Event.event_like<tag>

  include MakeEvent({
    type t = t
  })

  @send external make: unit => t = "make"
}

module TouchEvent = {
  type tag
  type t = Event.event_like<tag>

  include MakeEvent({
    type t = t
  })

  @send external make: unit => t = "make"
}

let mouseEvent = MouseEvent.make()
let touchEvent = MouseEvent.make()

let mouseTarget = Event.target(mouseEvent)
let touchTarget = Event.target(touchEvent)

let mouseTarget2 = MouseEvent.target(mouseEvent)
// Below we get compile error
// let mouseTarget2 = TouchEvent.target(mouseEvent)

We are using module functor to bring in the functions defined in the "parent's class" to the "inherited class".

Below is a simplified version if you don't need access to the "parent's class".

module Target = {
  type t
}

module MakeEvent = (
  Type: {
    type t
  },
) => {
  @get external target: Type.t => Target.t = "target"
}

type event_like<'a>

module MouseEvent = {
  type tag
  type t = event_like<tag>

  include MakeEvent({
    type t = t
  })

  @send external make: unit => t = "make"
}

module TouchEvent = {
  type tag
  type t = event_like<tag>

  include MakeEvent({
    type t = t
  })

  @send external make: unit => t = "make"
}

let mouseEvent = MouseEvent.make()
let touchEvent = MouseEvent.make()

let mouseTarget = MouseEvent.target(mouseEvent)
let touchTarget = MouseEvent.target(touchEvent)

Dealing with unsafe function

There will be time that you need to do "unsafe" operations. Here are some of them,

  • Returning a polymorphic type.
  • Dealing with Obj.magic to magically cast between types.

My general rules of thumb are

  • Always create a wrapper function that hides the implementation of the unsafe function.
  • Unsafe function should be explicit.

This is a similar approach taken by Rust community when dealing with unsafe.

Here's an example to show this.

type t

type gradient = {
    "start": string,
    "stop": string
}

type color =
    | Solid(string)
    | Gradient(gradient)

@get external unsafe_color: t => 'colorVariant = "color"

unsafe_color is very explicit in its naming that this function is unsafe because it returns a polymorphic type - i.e. it could literally be anything and it will always type checked.

To make this safe to use, we need a type safe wrapper.

let color = (t: t): option<color> => {
  let color = unsafe_color(t)

  switch Js.Types.classify(color) {
  | Js.Types.JSString(color) => Solid(color)->Some
  | Js.Types.JSObject(object) => Obj.magic(object)->Gradient->Some
  | _ => None
  }
}

Our type safe wrapper handles the runtime check and the proper conversion to ReScript types. From API user point of view, they don't have to care about the implementation details. Of course, this function needs to be reviewed properly to ensure it can't throw a runtime error and everything is checked properly.

Also, note that this wrapper is not zero-cost as it is just an ordinary ReScript function and not an external (binding).


I hope that you find this helpful!

Show Comments