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
Section titled “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
Section titled “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.
@sendexternal 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.
@sendexternal 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.
Constructor overloads
Section titled “Constructor overloads”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 typedfrom*overloads instead of keeping the optional argument onmake.
For example, these constructor families are easier to understand than numbered overloads:
@newexternal fromString: (~family: string, ~source: string) => fontFace = "FontFace"
@newexternal fromDataView: (~family: string, ~source: DataView.t) => fontFace = "FontFace"
@newexternal fromArrayBuffer: (~family: string, ~source: ArrayBuffer.t) => fontFace = "FontFace"And if a constructor really does have a default form, split it instead of hiding it:
@newexternal make: unit => domMatrix = "DOMMatrix"
@newexternal fromString: string => domMatrix = "DOMMatrix"
@newexternal 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.
Decoded variants
Section titled “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:
stringCanvasGradientCanvasPattern
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.
type fillStyle
type canvasRenderingContext2D = {// ... other propetiesmutable 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:
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
// Writectx.fillStyle = FillStyle.fromString("red")
// Readswitch 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.