Language concepts
The concepts covered in the following sections are a small subset of the OCaml language. However, they are essential for understanding how to communicate with JavaScript, and the features that Melange exposes to do so.
Extension nodes and attributes
In order to interact with JavaScript, Melange needs to extend the language to provide blocks that express these interactions.
One approach could be to introduce new syntactic constructs (keywords and such) to do so, for example:
javascript add : int -> int -> int = {|function(x,y){
return x + y
}|}
But this would break compatibility with OCaml, which is one of the main goals of Melange.
Fortunately, OCaml provides mechanisms to extend its language without breaking compatibility with the parser or the language. These mechanisms are composed by two parts:
- First, some syntax additions to define parts of the code that will be extended or replaced
- Second, compile-time OCaml native programs called PPX rewriters, that will read the syntax additions defined above and proceed to extend or replace them
The syntax additions come in two flavors, called extension nodes and attributes.
Extension nodes
Extension nodes are blocks that are supposed to be replaced by a specific type of PPX rewriters called extenders. Extension nodes use the %
character to be identified. Extenders will take the extension node and replace it with a valid OCaml AST (abstract syntax tree).
An example where Melange uses extension nodes to communicate with JavaScript is to produce "raw" JavaScript inside a Melange program:
[%%mel.raw "var a = 1; var b = 2"]
let add = [%mel.raw "a + b"]
[%%mel.raw "var a = 1; var b = 2"];
let add = [%mel.raw "a + b"];
Which will generate the following JavaScript code:
var a = 1; var b = 2
var add = a + b
The difference between one and two percentage characters is detailed in the OCaml documentation.
Attributes
Attributes are "decorations" applied to specific parts of the code to provide additional information. In Melange, attributes are used in two ways to enhance the expressiveness of generating JavaScript code: either reusing existing OCaml built-in attributes or defining new ones.
Reusing OCaml attributes
The first approach is leveraging the existing OCaml’s built-in attributes to be used for JavaScript generation.
One prominent example of OCaml attributes that can be used in Melange projects is the unboxed
attribute, which optimizes the compilation of single-field records and variants with a single tag to their raw values. This is useful when defining type aliases that we don’t want to mix up, or when binding to JavaScript code that uses heterogeneous collections. An example of the latter is discussed in the variadic function arguments section.
For instance:
type name =
| Name of string [@@unboxed]
let student_name = Name "alice"
[@unboxed]
type name =
| Name(string);
let student_name = Name("alice");
Compiles into:
var student_name = "alice";
Other OCaml pre-built attributes like alert
or deprecated
can be used with Melange as well.
Defining new attributes
The second approach is introducing new attributes specifically designed for Melange, such as the mel.set
attribute used to bind to properties of JavaScript objects. The complete list of attributes introduced by Melange can be found here.
Attribute annotations can use one, two or three @
characters depending on their placement in the code and which kind of syntax tree node they are annotating. More information about attributes can be found in the dedicated OCaml manual page.
Here are some samples using Melange attributes mel.set
and mel.as
:
type document
external setTitleDom : document -> string -> unit = "title" [@@mel.set]
type t = {
age : int; [@mel.as "a"]
name : string; [@mel.as "n"]
}
type document;
[@mel.set] external setTitleDom: (document, string) => unit = "title";
type t = {
[@mel.as "a"]
age: int,
[@mel.as "n"]
name: string,
};
To learn more about preprocessors, attributes and extension nodes, check the section about PPX rewriters in the OCaml docs.
External functions
Most of the system that Melange exposes to communicate with JavaScript is built on top of an OCaml language construct called external
.
external
is a keyword for declaring a value in OCaml that will interface with C code:
external my_c_function : int -> string = "someCFunctionName"
external my_c_function: int => string = "someCFunctionName";
It is like a let
binding, except that the body of an external is a string. That string has a specific meaning depending on the context. For native OCaml, it usually refers to a C function with that name. For Melange, it refers to the functions or values that exist in the runtime JavaScript code, and will be used from Melange.
In Melange, externals can be used to bind to global JavaScript objects. They can also be decorated with certain [@mel.xxx]
attributes to facilitate the creation of bindings in specific scenarios. Each one of the available attributes will be further explained in the next sections.
Once declared, one can use an external
as a normal value. Melange external functions are turned into the expected JavaScript values, inlined into their callers during compilation, and completely erased afterwards. They don’t appear in the JavaScript output, so there are no costs on bundling size.
Note: it is recommended to use external functions and the [@mel.xxx]
attributes in the interface files as well, as this allows some optimizations where the resulting JavaScript values can be directly inlined at the call sites.
Special identity external
One external worth mentioning is the following one:
type foo = string
type bar = int
external danger_zone : foo -> bar = "%identity"
type foo = string;
type bar = int;
external danger_zone: foo => bar = "%identity";
This is a final escape hatch which does nothing but convert from the type foo
to bar
. In the following sections, if you ever fail to write an external
, you can fall back to using this one. But try not to.
Abstract types
In the subsequent sections, you will come across examples of bindings where a type is defined without being assigned to a value. Here is an example:
type document
type document;
These types are referred to as "abstract types" and are commonly used together with external functions that define operations over values when communicating with JavaScript.
Abstract types enable defining types for specific values originating from JavaScript while omitting unnecessary details. An illustration is the document
type mentioned earlier, which has several properties. By using abstract types, one can focus solely on the required aspects of the document
value that the Melange program requires, rather than defining all its properties. Consider the following example:
type document
external document : document = "document"
external set_title : document -> string -> unit = "title" [@@mel.set]
type document;
external document: document = "document";
[@mel.set] external set_title: (document, string) => unit = "title";
Subsequent sections delve into the details about the mel.set
attribute and how to bind to global values like document
.
For a comprehensive understanding of abstract types and their usefulness, refer to the "Encapsulation" section of the OCaml Cornell textbook.
Pipe operators
There are two pipe operators available in Melange:
- A pipe last operator
|>
, available in OCaml and inherited in Melange - A pipe first operator
|.
->
, available exclusively in Melange
Let’s see the differences between the two.
Pipe last
Since version 4.01, OCaml includes a reverse application or "pipe" (|>
) operator, an infix operator that applies the result from the previous expression the next function. As a backend for OCaml, Melange inherits this operator.
The pipe operator could be implemented like this (the real implementation is a bit different):
let ( |> ) f g = g f
let (|>) = (f, g) => g(f);
This operator is useful when multiple functions are applied to some value in sequence, with the output of each function becoming the input of the next (a pipeline).
For example, assuming we have a function square
defined as:
let square x = x * x
let square = x => x * x;
We are using it like:
let ten = succ (square 3)
let ten = succ(square(3));
The pipe operator allows to write the computation for ten
in left-to-right order, as it has left associativity:
let ten = 3 |> square |> succ
let ten = 3 |> square |> succ;
When working with functions that can take multiple arguments, the pipe operator works best when the functions take the data we are processing as the last argument. For example:
let sum = List.fold_left ( + ) 0
let sum_sq =
[ 1; 2; 3 ]
|> List.map square (* [1; 4; 9] *)
|> sum (* 1 + 4 + 9 *)
let sum = List.fold_left((+), 0);
let sum_sq =
[1, 2, 3]
|> List.map(square) /* [1; 4; 9] */
|> sum; /* 1 + 4 + 9 */
The above example can be written concisely because the List.map
function in the OCaml standard library takes the list as the second argument. This convention is sometimes referred to as "data-last", and is widely adopted in the OCaml ecosystem. Data-last and the pipe operator |>
work great with currying, so they are a great fit for the language.
However, there are some limitations when using data-last when it comes to error handling. In the given example, if we mistakenly used the wrong function:
let sum_sq =
[ 1; 2; 3 ]
|> List.map String.cat
|> sum
let sum_sq = [1, 2, 3] |> List.map(String.cat) |> sum;
The compiler would rightfully raise an error:
4 | [ 1; 2; 3 ]
^
Error: This expression has type int but an expression was expected of type
string
1 | [ 1, 2, 3 ]
^
Error: This expression has type int but an expression was expected of type
string
Note that instead of telling us that we are passing the wrong function in List.map
(String.cat
), the error points to the list itself. This behavior aligns with the way type inference works, as the compiler infers types from left to right. Since [ 1; 2; 3 ] |> List.map String.cat
is equivalent to List.map String.cat [ 1; 2; 3 ]
, the type mismatch is detected when the list is type checked, after String.cat
has been processed.
With the goal of addressing this kind of limitations, Melange introduces the pipe first operator |.
->
.
Pipe first
To overcome the constraints mentioned above, Melange introduces the pipe first operator |.
->
.
As its name suggests, the pipe first operator is better suited for functions where the data is passed as the first argument.
The functions in the Belt
libraryBelt
library included with Melange have been designed with the data-first convention in mind, so they work best with the pipe first operator.
For example, we can rewrite the example above using Belt.List.map
and the pipe first operator:
let sum_sq =
[ 1; 2; 3 ]
|. Belt.List.map square
|. sum
let sum_sq = [1, 2, 3]->(Belt.List.map(square))->sum;
We can see the difference on the error we get if the wrong function is passed to Belt.List.map
:
let sum_sq =
[ 1; 2; 3 ]
|. Belt.List.map String.cat
|. sum
let sum_sq = [1, 2, 3]->(Belt.List.map(String.cat))->sum;
The compiler will show this error message:
4 | |. Belt.List.map String.cat
^^^^^^^^^^
Error: This expression has type string -> string -> string
but an expression was expected of type int -> 'a
Type string is not compatible with type int
2 | let sum_sq = [1, 2, 3]->(Belt.List.map(String.cat))->sum;
^^^^^^^^^^
Error: This expression has type string -> string -> string
but an expression was expected of type int -> 'a
Type string is not compatible with type int
The error points now to the function passed to Belt.List.map
, which is more natural with the way the code is being written.
Melange supports writing bindings to JavaScript using any of the two conventions, data-first or data-last, as shown in the "Chaining" section.
For further details about the differences between the two operators, the data-first and data-last conventions and the trade-offs between them, one can refer to this related blog post.