All Projects → yawaramin → bucklescript-bindings-cookbook

yawaramin / bucklescript-bindings-cookbook

Licence: MIT license
Task-oriented guide to writing JavaScript bindings for BuckleScript

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

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.

Note that the project description data, including the texts, logos, images, and/or trademarks, for each open source project belongs to its rightful owner. If you wish to add or remove any projects, please contact us at [email protected].