BuckleScript Bindings Cookbook
WRITING BuckleScript bindings can be somewhere between an art and a science, taking some learning investment into both the JavaScript and OCaml/Reason type systems to get a proper feel for it.
This cookbook aims to be a quickstart, task-focused guide for writing bindings. The idea is that you have in mind some JavaScript that you want to write, and look up the binding that should (hopefully) produce that output JavaScript.
Along the way, I will try to introduce standard types for modelling various JavaScript data.
Contents
- Globals
- Modules
- const path = require('path'); path.join('a', 'b') // function in CJS/ES module
- const foo = require('foo'); foo(1) // import entire module as a value
- import foo from 'foo'; foo(1) // import ES6 module default export
- const foo = require('foo'); foo.bar.baz() // function scoped inside an object in a module
- Functions
- const dir = path.join('a', 'b', ...) // function with rest args
- const nums = range(start, stop, step) // call a function with named arguments for readability
- foo('hello'); foo(true) // overloaded function
- const nums = range(start, stop, [step]) // optional final argument(s)
- mkdir('src/main', {recursive: true}) // options object argument
- forEach(start, stop, item => console.log(item)) // model a callback
- Objects
- Classes and OOP
- I don't see what I need here
- const foo = new Foo() // call a class constructor
- const bar = foo.bar // get an instance property
- foo.bar = 1 // set an instance property
- foo.meth() // call a nullary instance method
- const newStr = str.replace(substr, newSubstr) // non-mutating instance method
- arr.sort(compareFunction) // mutating instance method
- Null and undefined
Globals
window // global variable
[@bs.val] external window: Dom.window = "window";
Ref https://reasonml.org/docs/reason-compiler/latest/bind-to-global-values
window? // does global variable exist
switch ([%external window]) {
| Some(_) => "window exists"
| None => "window does not exist"
};
[%external NAME]
makes NAME
available as an value of type option('a)
, meaning its wrapped value is compatible with any type. I recommend that, if you use the value, to cast it safely into a known type first.
Ref https://reasonml.org/docs/reason-compiler/latest/embed-raw-javascript#detect-global-variables
Math.PI // variable in global module
[@bs.val] [@bs.scope "Math"] external pi: float = "PI";
Ref https://reasonml.org/docs/reason-compiler/latest/bind-to-global-values#global-modules
console.log // function in global module
[@bs.val] [@bs.scope "console"] external log: 'a => unit = "log";
Note that in JavaScript, console.log()
's return value is undefined
, which we can model with the unit
type.
Modules
const path = require('path'); path.join('a', 'b') // function in CJS/ES module
[@bs.module "path"] external join: (string, string) => string = "join";
let dir = join("a", "b");
Ref: https://reasonml.org/docs/reason-compiler/latest/import-export#import
const foo = require('foo'); foo(1) // import entire module as a value
[@bs.module] external foo: int => unit = "foo";
let () = foo(1);
Ref: https://reasonml.org/docs/reason-compiler/latest/import-export#import-a-default-value
import foo from 'foo'; foo(1) // import ES6 module default export
[@bs.module "foo"] external foo: int => unit = "default";
let () = foo(1);
Ref: https://reasonml.org/docs/reason-compiler/latest/import-export#import-an-es6-default-value
const foo = require('foo'); foo.bar.baz() // function scoped inside an object in a module
module Foo = {
module Bar = {
[@bs.module "foo"] [@bs.scope "bar"] external baz: unit => unit = "baz";
};
};
let () = Foo.Bar.baz();
It's not necessary to nest the binding inside Reason modules, but mirroring the structure of the JavaScript module layout does make the binding more discoverable.
Note that [@bs.scope]
works not just with [@bs.module]
, but also with [@bs.val]
(as shown earlier), and with combinations of [@bs.module]
, [@bs.new]
(covered in the OOP section), etc.
Tip: the [@bs.scope ...]
attribute supports an arbitrary level of scoping by passing the scope as a tuple argument, e.g. [@bs.scope ("a", "b", "c")]
.
Functions
const dir = path.join('a', 'b', ...) // function with rest args
[@bs.module "path"] [@bs.variadic] external join: array(string) => string = "join";
let dir = join([|"a", "b", ...|]);
Note that the rest args must all be of the same type for [@bs.variadic]
to work. If they really have different types, then more advanced techniques are needed.
Ref: https://reasonml.org/docs/reason-compiler/latest/function#variadic-function-arguments
const nums = range(start, stop, step) // call a function with named arguments for readability
[@bs.val] external range: (~start: int, ~stop: int, ~step: int) => array(int) = "range";
let nums = range(~start=1, ~stop=10, ~step=2);
foo('hello'); foo(true) // overloaded function
[@bs.val] external fooString: string => unit = "foo";
[@bs.val] external fooBool: bool => unit = "foo";
fooString("");
fooBool(true);
Because BuckleScript bindings allow specifying the name on the Reason side and the name on the JavaScript side (in quotes) separately, it's easy to bind multiple times to the same function with different names and signatures. This allows binding to complex JavaScript functions with polymorphic behaviour.
const nums = range(start, stop, [step]) // optional final argument(s)
[@bs.val] external range: (~start: int, ~stop: int, ~step: int=?, unit) => array(int) = "range";
let nums = range(~start=1, ~stop=10, ());
If a Reason function or binding has an optional parameter, it needs a positional parameter at the end of the parameter list to help the compiler understand when function application is finished and the function can actually execute. If this seems tedious, remember that no other language gives you out-of-the-box curried parameters and named parameters and optional parameters.
mkdir('src/main', {recursive: true}) // options object argument
type mkdirOptions;
[@bs.obj] external mkdirOptions: (~recursive: bool=?, unit) => mkdirOptions = "";
[@bs.val] external mkdir: (string, ~options: mkdirOptions=?, unit) => unit = "mkdir";
// Usage:
let () = mkdir("src", ());
let () = mkdir("src/main", ~options=mkdirOptions(~recursive=true, ()), ());
The [@bs.obj]
attribute allows creating a function that will output a JavaScript object. There are simpler ways to create JavaScript objects (see OOP section), but this is the only way that allows omitting optional fields like recursive
from the output object. By making the binding parameter optional (~recursive: bool=?
), you indicate that the field is also optional in the object.
Alternative way
Calling a function like mkdir("src/main", ~options=..., ())
can be syntactically pretty heavy, for the benefit of allowing the optional argument. But there is another way: binding to the same underlying function twice and treating the different invocations as overloads.
[@bs.val] external mkdir: string => unit = "mkdir";
[@bs.val] external mkdirWith: (string, mkdirOptions) => unit = "mkdir";
// Usage:
let () = mkdir("src/main");
let () = mkdirWith("src/main", mkdirOptions(~recursive=true, ()));
This way you don't need optional arguments, and no final ()
argument for mkdirWith
.
Ref: https://reasonml.org/docs/reason-compiler/latest/object-2#function
forEach(start, stop, item => console.log(item)) // model a callback
[@bs.val] external forEach: (~start: int, ~stop: int, [@bs.uncurry] int => unit) => unit = "forEach";
forEach(1, 10, Js.log);
When binding to functions with callbacks, you'll want to ensure that the callbacks are uncurried. [@bs.uncurry]
is the recommended way of doing that. However, in some circumstances you may be forced to use the static uncurried function syntax. See the docs for details.
Ref: https://reasonml.org/docs/reason-compiler/latest/function#extra-solution
Objects
const person = {id: 1, name: 'Bob'} // create an object
let person = {"id": 1, "name": "Bob"};
Ref: https://reasonml.org/docs/reason-compiler/latest/object-2#literal
person.name // get a prop
person##name
Ref: https://reasonml.org/docs/reason-compiler/latest/object-2#read
person.id = 0 // set a prop
person##id #= 0
Ref: https://reasonml.org/docs/reason-compiler/latest/object-2#write
const {id, name} = person // object with destructuring
type person = {id: int, name: string};
let person = {id: 1, name: "Bob"};
let {id, name} = person;
Since BuckleScript version 7, Reason record types compile to simple JavaScript objects. But you get the added benefits of pattern matching and immutable update syntax on the Reason side. There are a couple of caveats though:
- The object will contain all defined fields; none will be left out, even if they are optional types
- If you are referring to record fields defined in other modules, you must prefix at least one field with the module name, e.g.
let {Person.id, name} = person
Ref: https://reasonml.org/docs/reason-compiler/latest/object#records-as-objects
Classes and OOP
In BuckleScript it's idiomatic to bind to class properties and methods as functions which take the instance as just a normal function argument. So e.g., instead of
const foo = new Foo();
foo.bar();
You will write:
let foo = Foo.make();
let () = Foo.bar(foo);
Note that many of the techniques shown in the Functions section are applicable to the instance members shown below.
I don't see what I need here
Try looking in the Functions section; in BuckleScript functions and instance methods can share many of the same binding techniques.
const foo = new Foo() // call a class constructor
// Foo.re
// or,
// module Foo = {
type t;
// The `Foo` at the end must be the name of the class
[@bs.new] external make: unit => t = "Foo";
//}
...
let foo = Foo.make()
Note the abstract type t
. In BuckleScript you will model any class that's not a shared data type as an abstract data type. This means you won't expose the internals of the definition of the class, only its interface (accessors, methods), using functions which include the type t
in their signatures. This is shown in the next few sections.
A BuckleScript function binding doesn't have the context that it's binding to a JavaScript class like Foo
, so you will want to explicitly put it inside a corresponding module Foo
to denote the class it belongs to. In other words, model JavaScript classes as BuckleScript modules.
Ref: https://reasonml.org/docs/reason-compiler/latest/class#new
const bar = foo.bar // get an instance property
// In module Foo:
[@bs.get] external bar: t => int = "bar";
...
let bar = Foo.bar(foo);
Ref: https://reasonml.org/docs/reason-compiler/latest/property-access#static-property-access
foo.bar = 1 // set an instance property
// In module Foo:
[@bs.set] external setBar: (t, int) => unit = "bar"; // note the name
...
let () = Foo.setBar(foo, 1);
foo.meth() // call a nullary instance method
// In module Foo:
[@bs.send] external meth: t => unit = "meth";
...
let () = Foo.meth(foo);
Ref: https://reasonml.org/docs/reason-compiler/latest/function#object-method
const newStr = str.replace(substr, newSubstr) // non-mutating instance method
[@bs.send.pipe: string] external replace: (~substr: string, ~newSubstr: string) => string = "replace";
let newStr = replace(~substr, ~newSubstr, str);
[@bs.send.pipe]
injects a parameter of the given type (in this case string
) as the final positional parameter of the binding. In other words, it creates the binding with the real signature (~substr: string, ~newSubstr: string, string) => string
. This is handy for non-mutating functions as they traditionally take the instance as the final parameter.
It wasn't strictly necessary to use named arguments in this binding, but it helps readability with multiple arguments, especially if some have the same type.
Also note that you don't strictly need to use [@bs.send.pipe]
; if you want you can use [@bs.send]
everywhere.
arr.sort(compareFunction) // mutating instance method
[@bs.send] external sort: (array('a), [@bs.uncurry] ('a, 'a) => int) => array('a) = "sort";
let _ = sort(arr, compare);
For a mutating method, it's traditional to pass the instance argument first.
Note: compare
is a function provided by the standard library, which fits the defined interface of JavaScript's comparator function.
Null and undefined
foo.bar === undefined // check for undefined
[@bs.get] external bar: t => option(int) = "bar";
switch (Foo.bar(foo)) {
| Some(value) => ...
| None => ...
}
If you know some value may be undefined
(but not null
, see next section), and if you know its type is monomorphic (i.e. not generic), then you can model it directly as an option(...)
type.
Ref: https://reasonml.org/docs/reason-compiler/latest/null-undefined-option
foo.bar == null // check for null or undefined
[@bs.get] [@bs.return nullable] external bar: t => option(t);
switch (Foo.bar(foo)) {
| Some(value) => ...
| None => ...
}
If you know the value is 'nullable' (i.e. could be null
or undefined
), or if the value could be polymorphic, then [@bs.return nullable]
is appropriate to use.
Note that this attribute requires the return type of the binding to be an option(...)
type as well.