There’s a classic problem that arises when choosing to work with “obscure” programming languages, the core of which can be described in two parts:
- You really like language A
- The rest of the world has never heard of (or worse, dislikes) language A
In 2017, I developed a passion for all things ML when I was introduced to ReasonML and BuckleScript. The promise of these projects was simple:
- ReasonML: make OCaml syntax easier to grasp for newcomers used to C-style languages
- BuckleScript: provide an OCaml-to-JS compiler that emits readable code
Writing React code with these tools was next-level in terms of developer ergonomics. In a way, even though ReasonML was released years after React, it felt like the two were made for each other.
Imagine that you’re a brand new OCaml programmer wanting to take advantage of Js_of_ocaml. What's the first step of learning any new language or tool? Writing a basic Hello World program! Naively, a new JSOO programmer might expect the following to do the trick:
(** Call [console.log] with the string "Hello world!" *) let () = Js_of_ocaml.Firebug.console##log "Hello world!"
However, if we take a look in our console, we see that we don’t quite get what we were expecting:
Strings are arrays of UTF-16 values, whereas in OCaml, a
string is an arbitrary sequence of bytes. For this reason, JSOO needs to carry around some metadata about the string so that operations on it behave the same as in the native backend.
let () = let l = String.length "😀" in print_int l
var l = "😀".length; console.log(l);
Because “😀” is
0xF0 0x9F 0x98 0x80 in UTF-8, the OCaml version prints
4. But “😀” is
2. Now we can see why JSOO needs to keep that seemingly extraneous information around – imagine if basic functions like
String.sub were non-deterministic!
Diving a bit deeper into the JSOO docs yields the function
Js.string, which has type signature
string -> js_string Js.t. In JSOO, the type
let () = Js_of_ocaml.Firebug.console##log (Js.string "Hello world!")
Js.string. There’s also the inverse of this function,
These "converter" functions, combined with the useful
Say I have a function
f that takes a
string as input and returns another
string as output. We’ll be considering this trivial function:
let f str = str |> String.trim |> String.escaped
Although we know the signature for
f, we can add some annotations to make the types available at the AST-level:
let f (str : string) : string = str |> String.trim |> String.escaped
That’s exactly the goal of ppx_expjs 😀.
Let’s walk through some examples of the ppx, with the first one being the function we wrote above.
The most basic usage of the ppx simply involves adding the the
[@@expjs] attribute to any value binding:
let f (str : string) : string = str |> String.trim |> String.escaped [@@expjs]
Running the ppx on this one-liner yields the following (admittedly scary) output:
let __ppx_expjs_export = Js_of_ocaml.Js.Unsafe.obj [||] let f (str : string) = ((str |> String.trim) |> String.escaped : string)[@@expjs ] let () = Js_of_ocaml.Js.Unsafe.set __ppx_expjs_export (Js_of_ocaml.Js.string "f") (fun str -> Js_of_ocaml.Js.string (f (Js_of_ocaml.Js.to_string str))) let () = Js_of_ocaml.Js.export_all __ppx_expjs_export
However, once you get over the initial shock, the behavior is actually pretty simple. Let’s break it down line-by-line.
let __ppx_expjs_export = Js_of_ocaml.Js.Unsafe.obj [||]
The first line initializes the root “export object”. In the ppx, each module has its own associated “export object” which is used to collect all the exported values for that particular module.
let f (str : string) = ((str |> String.trim) |> String.escaped : string)[@@expjs ]
This is just our original function, in all its glory.
let () = Js_of_ocaml.Js.Unsafe.set __ppx_expjs_export (Js_of_ocaml.Js.string "f") (fun str -> Js_of_ocaml.Js.string (f (Js_of_ocaml.Js.to_string str)))
And this is where all the magic happens. Let’s first focus on the third line of this block. We can see here that the ppx constructed an anonymous function that does a couple things:
- Takes an argument named
- Passes that argument to
- Calls our function
fwith that converted string
- Passes the return value from our function over to
The second line simply binds this anonymous function to the export object with the name of the OCaml value. Ok, phew, almost done.
let () = Js_of_ocaml.Js.export_all __ppx_expjs_export
Finally, we expose all of the exports we colllected to the global object (
Welcome to Node.js v16.13.0. Type ".help" for more information. > const x = require("./_build/default/example/ex.bc.js"); undefined > x.f("\tFoo\tBar\n"); 'Foo\\tBar'
Let’s try a more practical example: writing React components as we described earlier. Using the amazing JSOO bindings to React by @jchavarri and @davesnx, we can construct a basic button component:
let%component button ~(text : string) ?(color : string = "red") ?(hover_color : string = "orange") () = let hovered, setHovered = React.use_state (fun () -> false) in button [| Prop.style @@ React.Dom.Style.make [| Style.background_color (if hovered then hover_color else color) Style.display "block"; Style.border "0"; |]; Prop.onMouseEnter (fun _ -> setHovered (fun _ -> true)); Prop.onMouseLeave (fun _ -> setHovered (fun _ -> false)); Prop.onClick (fun _ -> Js_of_ocaml.(Dom_html.window##alert (Js.string "Hello world!"))); |] [ React.string text ] [@@expjs]
const React = require("react"); const ReactDOM = require("react-dom"); const caml = require("./ppx_expjs_react.bc.js"); const elt = <div> <caml.button text="ppx_expjs rules" color="lightblue" hover_color="crimson" /> </div> ReactDOM.render(elt, document.getElementById("react-root"))
After bundling and some other magic, we get 🥁…
…exactly the behavior we want!
You’ll also notice that I was able to use JSX to call the component. This is possible because, for all functions with labelled arguments, ppx_expjs will automatically generate an object argument for the function. This allows the caller to use object literals in the typical JS-style as well as JSX.
If we compare the OCaml we wrote to what the ppx generated, the time and effort saved on the developer’s behalf is stark.
let%component button ~(text : string) ?(color : string = "red") ?(hover_color : string = "orange") () = (* component implementation *) [@@expjs]
let __ppx_expjs_export = Js_of_ocaml.Js.Unsafe.obj [||] let%component button ~(text : string) ?(color : string = "red") ?(hover_color : string = "orange") () = (* component implementation *) [@@expjs] let () = Js_of_ocaml.Js.Unsafe.set __ppx_expjs_export (Js_of_ocaml.Js.string "button") (fun labelled -> fun () -> button ~text:(Js_of_ocaml.Js.to_string (Ppx_expjs_runtime.get_required labelled "text")) ?color:(Option.map Js_of_ocaml.Js.to_string (Ppx_expjs_runtime.get_opt labelled "color")) ?hover_color:(Option.map Js_of_ocaml.Js.to_string (Ppx_expjs_runtime.get_opt labelled "hover_color")) ()) let () = Js_of_ocaml.Js.export_all __ppx_expjs_export
With that, hopefully I’ve demonstrated a practical use case for this ppx. For those curious enough to check it out for themselves, the code can be found here. If you end up trying it, don’t hesitate to provide feedback! Our GitHub issues are always open 😁.
Finally, I want to give a shout out to the people behind js_of_ocaml, AST Explorer, and jsoo-react. These projects greatly helped me in developing the ppx and elevate its usefulness as a tool.