Skip to content

Task-oriented guide to writing JavaScript bindings for ReScript

License

Notifications You must be signed in to change notification settings

rescriptbr/rescript-bindings-cookbook

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

ReScript Bindings Cookbook

This cookbook is an update to the yawaramin’s cookbook!

Introduction

Writing ReScript bindings can be somewhere between an art and a science, taking some learning investment into both the JavaScript and ReScript 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.

Raw JavaScript

ReScript:

let add = %raw("(a, b) => a + b")

Usage:

let result = add(2, 2)

Globals

Reference a global value

JavaScript:

setTimeout(() => console.log("Hey there"), 40 * 100);

ReScript:

@val external setTimeout: (unit => unit, int) => unit = "setTimeout"

Usage:

setTimeout(() => Js.Console.log("Hey There"), 40 * 100)

Ref: https://rescript-lang.org/docs/manual/latest/interop-cheatsheet#global-value

Check if the global value exists

JavaScript:

if (window) console.log("window exists")
else console.log("window does not exist")

ReScript:

swicth (%external window) {
| Some(_) => Js.Console.log("window exists")
| None => Js.Console.log("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 know type first.

Reference a variable in a global module

JavaScript:

Math.PI;

ReScript:

@val @scope("Math") 
external pi: float = "PI"

Usage:

let circleArea = (~radio: float) => pi * (radio * radio)

Ref: https://rescript-lang.org/docs/manual/latest/interop-cheatsheet#global-modules-value

Modules

Function in CJS/ES module

JavaScript:

const path = require('path');

const dir = path.join('a', 'b');

ReScript:

@module("path") external join: (string, string) => string = "join"

Usage:

let dir = join("a", "b")

Ref: https://rescript-lang.org/docs/manual/latest/bind-to-js-function

Import entire module as a value

JavaScript:

const foo = require('foo')

ReScript:

@module external foo: int => unit = "foo"

Usage:

let () = foo(1)

Import ES6 module default export

JavaScript:

import foo from 'foo';

ReScript:

@module("foo") external foo: int => unit  "default"

Usage:

let () = foo(1)

A function scoped inside an object in a module

JavaScript:

import { foo } from 'foo';

foo.bar.baz();

ReScript:

module Foo = {
  module Bar = {
    @module("foo") @scope("bar")
    external baz: unit => unit = "baz"
  }
}

Usage:

let () = Foo.Bar.baz()

It’s not necessary to nest the binding inside ReScript modules, but mirroring the structure of the JavaScript module layout does make the binding more discoverable.

Note that @scope works not just with @module, but also with @val (as shown earlier), and with combinations of @module, @new (covered in the OOP section), etc.

Tip: the @scope(...) attribute supports an arbitrary level of scoping by passing the scope as a tuple argument, e.g. @scope(("a", "b", "c")).

Functions

Functions with rest args

JavaScript:

const path = require('path');

const xs = ['b', 'c'];
const dir = path.join('a', ...xs);

ReScript:

@module("path") @variadic
external join: array<string> => string = "join"

Usage:

let dir = join(["a", "b", "c"])

Note that the rest args must all be of the same type for @variadic to work. If they really have different types, then more advanced techniques are needed.

Ref: https://rescript-lang.org/docs/manual/latest/bind-to-js-function#variadic-function-arguments

Call a function with named arguments for readability

ReScript:

@val external range(~start: int, ~stop: int, ~step: int) => array<int> = "range"

Usage:

let nums = range(~start=1, ~stop=10, ~step=2)

Polymorphic function

JavaScript:

foo("string");
foo(true);

ReScript:

@val external fooString: string => unit = "foo"
@val external fooBool: bool => unit = "foo"

Usage:

fooString("string")
fooBool(false)

Ref: https://rescript-lang.org/docs/manual/latest/bind-to-js-function#modeling-polymorphic-function

Function with optional final argument(s)

JavaScript:

foo(1);
foo(1, 2);

ReScript:

@val external foo: (int, int=?) => unit = "foo"

Usage:

foo(1, ())
foo(1, 2)

If a ReScript 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.

Options object argument

JavaScript:

const fs = require('fs');

fs.mkdir('src', { recursive: true });

ReScript:

type mkdirOptions

@obj external mkdirOptions: (~recursive: bool=?, unit) => mkdirOptions = ""
@module("fs") external mkdir: (string, ~options: mkdirOptions=?, unit) => unit = "mkdir"

Usage:

let () = mkdir("src", ())
let () = mkdir("src/main", ~options=mkdirOptions(~recursive=true, ()), ())

The @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 (\nbsprecursive: bool=?), you indicate that the field is also optional in the object.

Alternative way

Calling a function like mkdir("src/main", \nbspoptions=..., ()) 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.

ReScript:

@module("fs") external mkdir: string => unit = "mkdir"
@module("fs") 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.

Function with callback

Javascript:

const fs = require('fs')

const cb = (err, data) => err ? console.log("ERROR") : console.log(data);
fs.readFile('./file.txt', cb);

ReScript:

type fileError = {
  errno: int,
  code: string,
  syscall: string,
  path: string
}

@module("fs") 
external readFile: (string, @uncurry (option<fileError>, option<string>) => unit) => unit = "readFile"

Usage:

readFile("./file.txt", (err, data) => {
  switch ((err, data)) {
  | (None, Some(data)) => Js.Conole.log(data)
  | (Some(_), None) => Js.Console.log("ERROR")
  | _ => Js.Console.log("That clause will never happen...")
  }
})

Objects

Create an object

JavaScript:

const person = {name: "jhon", age: 18};

const {name, age} = person;

ReScript:

type person = {
  name: string,
  age: int
}

let person = {name: "jhon", age: 18}
let {name, age} = person

Ref: https://rescript-lang.org/docs/manual/latest/bind-to-js-object#bind-to-record-like-js-objects

Classes and OOP

In ReScript 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’ll write:

let foo = Foo.make()
let () = Foo.bar(foo)

Note that many of techiniques shown in the Functions secton are applicable to the instance members shown below.

I don’t see what I need here

Try looking in the Functions section; in ReScript functions and instance methods can share many of the same binding techniques.

Call a class constructor

JavaScript:

const foo = new Foo();

ReScript:

// Foo.res or module Foo { ... }
type t

@new external make = unit => t = "Foo"

Usage:

let foo = Foo.make()

Note the abstract type t. In ReScript 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 ReScript 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 ReScript modules.

Ref: https://rescript-lang.org/docs/manual/latest/bind-to-js-object#bind-to-a-js-object-thats-a-class

Get a instance property

JavaScript:

const foo = new Foo();
let bar = foo.bar;

ReScript:

// In Foo.res or module Foo { ... }
// [...]
@get external bar: t => int "bar"

Usage:

let foo = Foo.make()
let bar = Foo.bar(foo)

Call a instance method

JavaScript:

const foo = new Foo();

foo.baz();

ReScript:

// In Foo.res or module Foo { ... }
@send external baz: t => unit = "baz"

Usage:

let foo = Foo.make()
let () = Foo.baz(foo)

Null and undefined

Check for undefined

JavaScript:

const foo = new Foo();

// if (!foo.bar)
if (foo.bar === undefined) console.log('undefined');

ReScript:

@get external bar: t => option<int> = "bar"

Usage:

let foo = Foo.make()
let bar = Foo.bar(foo)

switch (bar) {
| Some(val) => Js.Console.log(val)
| None => Js.Console.log("undefined")
}

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.

Check for null and undefined

JavaScript:

const foo = new Foo();

// if (!foo.bar)
if (foo.bar === null || foo.bar === undefined) 
  console.log("null or undefined");

ReScript:

@get @return(nullable) external bar: t => option<t>

Usage:

let foo = Foo.make()
let bar = Foo.bar(foo)

switch (bar) {
| Some(val) => Js.Console.log(val)
| None => Js.Console.log("undefined")
}

If you know the value is ‘nullable’ (i.e. could be null or undefined), or if the value could be polymorphic, then @return(nullable) is appropriate to use.

Note that this attribute requires the return type of the binding to be an option(...) type as well.

About

Task-oriented guide to writing JavaScript bindings for ReScript

Resources

License

Stars

Watchers

Forks