A high level overview of BuckleScript interop with Javascript
When users start to use BuckleScript to develop applications on JS platform, they have to interop with various APIs provided by the JS platform.
In theory, like Elm, BuckleScript could ship a comprehensive library which contains what most people would like to use daily. This, however, is particularly challenging, given that JS is running on so many platforms for example, Electron, Node and Browser, yet each platform is still evolving quickly. So we have to provide a mechanism to allow users to bind to the native JS API quickly in userland.
There are lots of trade-off when designing such a FFI bridge between OCaml and the JavaScript API. Below, we list a few key items which we think have an important impact on our design.
Interop design constraints
BuckleScript is still OCaml
We are not inventing a new language. In particular, we can not change the concrete syntax of OCaml. Luckily, OCaml introduced attributes and extension nodes since 4.02, which allows us to customize the language to a minor extent. To be a good citizen in the OCaml community, all attributes introduced by BuckleScript are prefixed with
bs
.Bare metal efficiency should always be possible for experts in pure OCaml
Efficiency is at the heart of BuckleScript's design philosophy, in terms of both compilation speed and runtime performance. While there were other strongly typed functional languages running on the JS platform before we made BuckleScript, one thing in particular that confused me was that in those languages, people have to write
native JS
to gain performance. Our goal is that when performance really matters, it is still possible for experts to write pure OCaml without digging intonative JS
, so users don't have to make a choice between performance and type safety.
Easy interop using raw JS
BuckleScript allows users to insert raw JS using extension nodes directly. Please refer to the documentation for details. Here we only talk about one of the most used styles: inserting raw JS code as a function.
let getSafe : int array -> int -> int = fun%raw a b -> {|
if (b>=0 && b < a.length) {
return a [b]
}
throw new Error("out of range")
|}
let v = getSafe [|1;2;3|] (-1)
let getSafe: (array(int), int) => int = [%raw
(a, b) => {|
if (b>=0 && b < a.length) {
return a [b]
}
throw new Error("out of range")
|}
];
let v = [|1, 2, 3|]->getSafe(-1);
Here the raw extension node asks the user to list the parameters and function statement in raw JS syntax. The generated JS code is as follows:
function getSafe (a,b){
if (b>=0 && b < a.length) {
return a [b]
}
throw new Error("out of range")
};
var v = getSafe(/* array */[
1,
2,
3
], -1);
Inserting raw JS code as a function has several advantages:
- It is relatively safe; there is no variable name polluting.
- It is quite expressive since the user can express everything inside the function body.
- The compiler still has some knowledge about the function, for example, its arity.
Some advice about using this style:
- Always annotate the raw function with explicit type annotation.
- When annotating raw JS, you can use polymorphic types, but don’t create them when you don’t really need them. In general, non polymoprhic type is safer and more efficient.
- Write a unit test for the function.
Note that a nice thing about this mechanism is that no separate JS file is needed, so no change to the build system is necessary in most cases. By using this mechanism, BuckleScript users can already deal with most bindings.
Interop via attributes
If you are a developer busy shipping, the mechanism above should cover almost everything you need. A minor disadvange of that mechanism is that it comes with a cost: a raw function can not be inlined since it is JavaScript, so the BuckleScript compiler does not have a deep knowledge about the function.
To demonstrate interop via attributes, we are going to show a small example of binding to JS date
. There are lots of advanced topics in the documentation; here we are only talking about one of the most-used methods for interop.
The key idea is to bind your JS object as an abstract data type where a data type is defined by its behavior from the point of view of a user of the data, instead of the data type’s concrete representations.
type date
external fromFloat : float -> date = "Date" [@@bs.new]
external getDate : date -> float = "getDate" [@@bs.send]
external setDate : date -> float -> unit = "setDate" [@@bs.send]
let date = fromFloat 10000.
let () = setDate date 3.
let d = getDate date
type date;
[@bs.new]
external fromFloat : float => date = "Date" ;
[@bs.send]
external getDate : date => float = "getDate" ;
[@bs.send]
external setDate : date => float => unit = "setDate";
let date = fromFloat (10000.0);
date->setDate (3.0);
let d = date -> getDate;
The preceding code generates the following JS. As you can see, the binding itself is zero cost and serves as formal documentation.
var date = new Date(10000);
date.setDate(3);
var d = date.getDate();
A typical workflow is that we create an abstract data type, create bindings for a “maker” using bs.new
, and bind methods using bs.send
.
Thanks to native support of abstract data types in OCaml, the interop is easy to reason about.
Some advice when using this style:
- Again, you can use polymorphic types in your annotations, but don't create polymorphic types when you don't need them.
- Write a unit test for each external.
As a comparison, we can create the same binding using raw
:
type date
let fromFloat : float -> date = fun%raw d -> {|return new Date(d)|}
let getDate : date -> float = fun%raw d -> {|return d.getDate()|}
let setDate : date -> float -> unit = fun%raw d v -> {|
d.setDate(v);
return 0; // ocaml representation of unit
|}
let date = fromFloat 10000.
let () = setDate date 3.
let d = getDate date
type date;
let fromFloat: float => date = [%raw d => {|return new Date(d)|}];
let getDate: date => float = [%raw d => {|return d.getDate()|}];
let setDate: (date, float) => unit = [%raw
(d, v) => {|
d.setDate(v);
return 0; // ocaml representation of unit
|}
];
let date = fromFloat(10000.);
date->setDate( 3.);
let d = date->getDate;
The generated JS is as follows, and you can see the cost:
function fromFloat (d){return new Date(d)};
function getDate (d){return d.getDate()};
function setDate (d,v){
d.setDate(v);
return 0; // ocaml representation of unit
};
var date = fromFloat(10000);
setDate(date, 3);
var d = getDate(date);