Skip to content

API Modelling

One of this projects goals is to provide a consistent and idiomatic API for the Web APIs. The interopt story of ReScript is quite good, but it it has limitations. JavaScript is a dynamic language and has a lot of flexibility. ReScript is a statically typed language and has to model the dynamic parts of JavaScript in a static way.

Dynamic parameters and properties

Some Web APIs have a parameter or property that can be multiple things. In ReScript, you would model this as a variant type. This is an example wrapper around values with a key property to discriminate them.

In JavaScript, this strictness is not enforced and you can pass a string where a number is expected. There are multiple strategies to model this in ReScript and it depends on the specific API which one is the best.

Overloads

One example is addEventListener.
This can access either a boolean or an object as the third parameter.

addEventListener(type, listener);
addEventListener(type, listener, options);
addEventListener(type, listener, useCapture);

Because, this is a method, we can model this as an overloaded function in ReScript.
The first two overloads are the same, so we can merge them into one with an optional options parameter.

@send
external addEventListener: (
htmlButtonElement,
eventType,
eventListener<'event>,
~options: addEventListenerOptions=?,
) => unit = "addEventListener"

The third overload takes a boolean and is worth using when you want to change the default of the useCapture boolean parameter.
We can use a fixed argument to model this.

@send
external addEventListener_useCapture: (
htmlButtonElement,
~type_: eventType,
~callback: eventListener<'event>,
@as(json`true`) _,
) => unit = "addEventListener"

Decoded variants

We can be pragmatic with overloaded functions and use model them in various creative ways.
For properties, we cannot do this unfortunately. A propery can only be defined once and have a single type.

The strategy here is to use a decoded variant.

Example for the fillStyle property of the CanvasRenderingContext2D interface can be either a:

  • string
  • CanvasGradient
  • CanvasPattern

These types are not all primitives and thus we cannot define it as untagged variants.
What we can do instead is represent the type as an empty type and use a helper module to interact with this.

DOMAPI.res
type fillStyle
type canvasRenderingContext2D = {
// ... other propeties
mutable fillStyle: fillStyle
}

When we wish to read and write the fillStyle property, we can use a helper module to lift the type to an actual ReScript variant:

DOMAPI/FillStyle.res
open Prelude
open CanvasAPI
open DOMAPI
external fromString: string => fillStyle = "%identity"
external fromCanvasGradient: canvasGradient => fillStyle = "%identity"
external fromCanvasPattern: canvasGradient => fillStyle = "%identity"
type decoded =
| String(string)
| CanvasGradient(canvasGradient)
| CanvasPattern(canvasPattern)
let decode = (t: fillStyle): decoded => {
if CanvasGradient.isInstanceOf(t) {
CanvasGradient(unsafeConversation(t))
} else if CanvasPattern.isInstanceOf(t) {
CanvasPattern(unsafeConversation(t))
} else {
String(unsafeConversation(t))
}
}

We can now use FillStyle.decode to get the actual value of the fillStyle property.
And use FillStyle.fromString, FillStyle.fromCanvasGradient, and FillStyle.fromCanvasPattern to set the value.

let ctx = myCanvas->HTMLCanvasElement.getContext_2D
// Write
ctx.fillStyle = FillStyle.fromString("red")
// Read
switch ctx.fillStyle->FillStyle.decode {
| FillStyle.String(color) => Console.log(`Color: ${color}`)
| FillStyle.CanvasGradient(_) => Console.log("CanvasGradient")
| FillStyle.CanvasPattern(_) => Console.log("CanvasPattern")
}

Try and use decoded and decode as conventions for the type and function names.