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.

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.

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 addEventListenerWithCapture: (
htmlButtonElement,
~type_: eventType,
~callback: eventListener<'event>,
@as(json`true`) _,
) => unit = "addEventListener"

When naming an overloaded function, we can use the With suffix to indicate that it is an overloaded function.

Constructors follow a different naming rule than methods.

  • Keep singleton constructors named make.
  • Keep true no-argument default constructors named make, even when the constructor family also has typed overloads.
  • When a constructor family does not have a default constructor, use from* names for every overload, including the first one.
  • Base from* names on the source input type: fromString, fromArrayBuffer, fromMediaStream, fromURLWithProtocols, and so on.
  • If an optional labeled argument hides a real default constructor, split that binding into make() plus typed from* overloads instead of keeping the optional argument on make.

For example, these constructor families are easier to understand than numbered overloads:

@new
external fromString: (~family: string, ~source: string) => fontFace = "FontFace"
@new
external fromDataView: (~family: string, ~source: DataView.t) => fontFace = "FontFace"
@new
external fromArrayBuffer: (~family: string, ~source: ArrayBuffer.t) => fontFace = "FontFace"

And if a constructor really does have a default form, split it instead of hiding it:

@new
external make: unit => domMatrix = "DOMMatrix"
@new
external fromString: string => domMatrix = "DOMMatrix"
@new
external fromArray: array<float> => domMatrix = "DOMMatrix"

Single-source constructor variants should take the source value directly without a label.

Keep makeWith* names for non-default convenience constructors that are not part of a source-type overload family.

Constructor naming is currently verified with compile-coverage tests. Runtime verification for these constructor families will be added later when the Vitest and happy-dom harness lands.

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 WebAPI
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.