Vdom 0.3 brings Elm-style decoders to OCaml.

Aurélien Saue

With the release of Vdom 0.3, OCaml developers now have access to a powerful new feature: Elm-like decoders. They provide an elegant and efficient means to access data fields within tree-like structures, such as JavaScript objects.

In this new version of the Vdom library, you can now define custom event handlers, moving away from the previously hard-coded system. Before this update, event handling was confined to a predefined set of event types, with limited data extraction capabilities. With the introduction of decoders, the approach becomes more flexible and extensible. This shift allows for more intricate operations and expands the potential use cases. Previously unsupported complex operations, such as retrieving files that users drag and drop, can now be defined with ease.

The Vdom library

The Vdom library, previously known as Ocaml-vdom, is an open-source project launched by LexiFi and designed for functional UI application development. Proven reliable through internal use at LexiFi since 2016, Vdom offers a solid solution for web application development with ongoing maintenance.

The library implements the Elm architecture and provides bindings to the Document Object Model (DOM) and various client-side JavaScript APIs. To learn more, visit its GitHub repository.

The recent release of Vdom version 0.3 in OPAM introduces a range of new features. Besides Elm-style decoders, this update also includes React-like fragments, native export functionality from VDOM to HTML, and an array of new JavaScript API bindings.

Abstract representation of decoders

At their core, decoders are functions that convert JavaScript objects into OCaml values.

However, the VDOM architecture is divided into a purely functional part independent of any JavaScript environment and a second one that actually implements the binding with JavaScript. This architecture has the advantage of being able to run unit tests, regression tests, and so on independently of any client-side environment. These tests can be compiled into native code and executed on the server side. In light of this, we aimed for an implementation-agnostic decoder representation. This led us to introduce an abstract syntax for decoders. While we do offer a converter for abstract decoders into actual functions (accepting Ojs.t as an argument), alternative concrete implementations are entirely feasible. These alternative implementations could use other representations of JavaScript objects or even extend to objects from other languages that share similar features, such as JSON.

The OCaml type used for the abstract representation is the following GADT (Generalized Algebraic Data Type):

module Decoder = struct
  type _ t =
    | String : string t
    | Int : int t
    | Float : float t
    | Bool : bool t
    | Object : js_object t
    | List : 'a t -> 'a list t
    | Field : string * 'msg t -> 'msg t
    | Method : string * arg_value list * 'msg t -> 'msg t
    | Bind : ('a -> 'msg t) * 'a t -> 'msg t
    | Const : 'msg -> 'msg t
    | Fail : string -> 'msg t
    | Try : 'a t -> 'a option t
    | Factor : ('a -> 'msg t) -> ('a -> ('msg, string) Result.t) t
end

Each value of type 'a Decoder.t represents a decoder that produces an OCaml value of type 'a. These constructors serve as building blocks for creating more complex decoders.

Building blocks of decoders

The first six constructors are the base building blocks. They attempt to convert input objects to an OCaml value of a specific type and raise an error if the input is incompatible.

For instance, Decoder.string, which is just a wrapper around Decoder.String, is a string decoder. When given a JavaScript value, it attempts to produce an OCaml string. If the input is a JavaScript string, the decoder succeeds; otherwise, it fails.

Decoder.object_ (or Object) is the decoder used for retrieving objects that cannot easily be converted to an OCaml value. It is different from the other building blocks in that it does not transform the JavaScript object in any way, it just returns it. The return type, js_object, is a forward reference to the type representing JavaScript objects, defined in the implementation of the decoder. This decoder acts as an escape hatch to access JavaScript objects while still remaining implementation agnostic.

Things become more interesting when we employ Decoder.field to access fields within JavaScript objects. Decoder.field "label" d is a decoder that accesses the field .label and then applies the decoder d. For example, to access the value of a text field input in the DOM, equivalent to accessing input.value in JavaScript, we can use the following string decoder:

let module D = Vdom.Decoder in
D.field "value" D.string

Nested fields can be accessed by nesting the constructor: field "target" (field "value" string) corresponds to accessing x.target.value. For convenience, we also support the shorthand field "target.value" string.

The next constructor, Decoder.method_, operates similarly, allowing you to call a method with a list of arguments and then apply a decoder to the result. While decoders are primarily designed for data retrieval rather than object manipulation, calling methods is needed in many practical scenarios. This is especially true when accessing pseudo-properties that are exposed as methods. For instance, within the DOM, one needs to invoke the getBoundingRect() method in order to obtain the bounding rectangle of an element.

The remaining five constructors are decoder combinators, introducing a higher level of decoding power. The most notable are the monadic operators Decoder.bind and Decoder.const. bind combines decoders sequentially, passing the result of one decoder to another. const serves as the constant decoder, always returning a specified value, regardless of the input JavaScript object.

Additionally, the Decoder.try_ constructor is designed to handle decoder failures by returning None when the nested decoder fails. The last two operators cater to more intricate use cases: Decoder.fail is intended to consistently produce a failure, while Decoder.factor offers a mechanism to delay the execution of a decoder.

A convenient way to use the bind operator is with the let* syntax, defined as let ( let* ) d f = bind f d. Consider the following decoder:

let open Vdom.Decoder in
let* value = field "value" string in
let* inner_html = field "innerHTML" string in
const (value, inner_html)

This decoder successively applies three decoders to the input: field "value" string, field "innerHTML" string, and const (value, inner_html). The first two extract the value and inner HTML of a DOM element, and the last one returns a pair of both extracted strings.

Practical applications

In the Vdom library, decoders are primarily useful in crafting custom event handlers. While the Vdom library previously allowed developers to create handlers for various browser events, these handlers provided a predefined set of “relevant” data from the event. However, this approach had limitations, as it could not accommodate an ever-expanding list of potentially useful fields for every new type of event.

With decoders, users gain access to any field within the event object, including results of method calls. Moreover, only the necessary fields are extracted, reducing unnecessary overhead.

Creating an event handler with a decoder is accomplished using Vdom.on. It requires a string specifying the event type and a 'msg option decoder. When the event occurs, the decoder is applied to the JavaScript Event object. If it succeeds and does not return None, the output is dispatched as a VDOM message.

As an example, here is how we can define an event handler that retrieves the list of files that a user drags and drops into a web application, and sends a VDOM message in response:

let files_decoder =
  let open Vdom.Decoder in
  let* files = field "dataTransfer.files" (list object_) in
  const (Some (Drop files))
in
Vdom.on ~prevent_default:() "drop" files_decoder

Decoders also serve as a versatile tool for general data access, with applications that extend beyond custom event handling. They provide a flexible means to interact with JavaScript objects and can be adapted for various data access tasks.

For all other potential use cases, Vdom also offers a function to manually execute a decoder, Vdom_blit.BDecoder.decode, which takes a decoder and a JavaScript value (of type Ojs.t) as input, and returns a Result.t value with either the result of the decoder or a string describing the part of the decoder that failed.