Jsont cookbook

A few conventions and recipes to describe JSON data with Jsont.

Conventions

Naming Jsont.t values

Given an OCaml type t its JSON type should be called t_jsont. If your type follows the M.t module convention use M.jsont.

Recipes

Dealing with numbers

JSON is utterly broken to interchange numbers reliably. Generally interopable implementations and in particular the formally specified and widely deployed ECMAScript implementation, use IEEE 754 binary64 values to represent JSON numbers. This has the following consequences.

Numbers as integers

For representing integers by JSON numbers one is limited to the range [-253;253] which are the only integers represented precisely in IEEE 754 binary64. If you want to represent numbers beyond this range you need to represent them by a JSON string. Two scheme can be seen in the wild:

For those integer sizes that can be fully represented in JSON number's integer range, like Jsont.uint8, Jsont.uint16, etc. only JSON numbers are used.

Numbers as nulls

JSON numbers cannot represent IEEE 754 binary64 numbers: infinities and NaNs cannot be represented. The most widely deployed and formally specified encoder, namely ECMAScript's JSON.stringify, replaces these values by null.

For this reason in Jsont the domain of Jsont.Base.number maps is JSON numbers or JSON null. In the decoding direction a null is mapped to Float.nan and in the encoding direction any float not satisfying Float.is_finite is mapped to a JSON null.

If you can agree with a third party on a better encoding, the Jsont.any_float or Jsont.float_as_hex_string provide lossless representations of IEEE 754 binary64 values in JSON.

Objects as records

Suppose our JSON object is:

{ "name": "Jane Doe"
   "age": 56 }

We represent it with an OCaml record as follows:

module Person = struct
  type t = { name : string; age : int }
  let make name age = { name; age }
  let name p = p.name
  let age p = p.age
  let jsont =
    Jsont.Obj.map make ~kind:"Person"
    |> Jsont.Obj.mem "name" Jsont.string ~enc:name
    |> Jsont.Obj.mem "age" Jsont.int ~enc:age
    |> Jsont.Obj.finish
end

Objects as key-value maps

JSON objects can be used as maps from strings to a single type of value (example). Such maps can be easily converted to OCaml as follows:

module String_map = Map.Make (String)

let map : ?kind:string -> 'a Jsont.t -> 'a String_map.t Jsont.t =
fun ?kind t ->
  let m = Jsont.Obj.Unknown.string_map t in
  let unknown = Jsont.Obj.Unknown.map m ~enc:Fun.id in
  Jsont.Obj.map' ?kind ~unknown Fun.id |> Jsont.Obj.finish

Since the pattern is common this is directly exposed as Jsont.Obj.as_string_map.

Optional members

By default members specified via Jsont.Obj.mem are mandatory and decoding errors if the member is absent.

For those cases where the member is optional a default dec_absent value must be specified to use on decoding when absent. For encoding an enc_omit function can be specified to determine whether the member should be omitted on encoding.

In the following example we use an option type to denote the absence of the age member:

module Person_opt_mem = struct
  type t = { name : string; age : int option }
  let make name age = { name; age }
  let name p = p.name
  let age p = p.age
  let jsont =
    Jsont.Obj.map make ~kind:"Person"
    |> Jsont.Obj.mem "name" Jsont.string ~enc:name
    |> Jsont.Obj.mem "age"
       ~dec_absent:None ~enc_omit:Option.is_none Jsont.(some int) ~enc:age
    |> Jsont.Obj.finish
end

When absence is represented by None like here the Jsont.Obj.opt_mem function can be used. It's stricly equivalent to the above but more concise.

Unknown object members

Dropping

By default Jsont.Obj.map drops unknown object members.

Preserving

When JSON formats allow other members than those they describe or when you want to partially model a JSON data schema you can collect unknown members into a Jsont.Json.obj value and store it in a field of your data structure. For example as follows:

module Person_preserve = struct
  type t = { name : string; age : int; unknown : Jsont.Json.obj; }
  let make unknown name age = { name; age; unknown }
  let name p = p.name
  let age p = p.age
  let unknown v = v.unknown
  let jsont =
    let unknown = Jsont.Obj.Unknown.map Jsont.Json.unknown_obj ~enc:unknown in
    Jsont.Obj.map' ~kind:"Person" ~unknown make
    |> Jsont.Obj.mem "name" Jsont.string ~enc:name
    |> Jsont.Obj.mem "age" Jsont.int ~enc:age
    |> Jsont.Obj.finish
end

The value of the unknown field can then easily be further queried by users with other JSON types by using Jsont.Json.decode.

Alternatively it can also be left polymorphic so that the client can provide a type for them:

module Person'' = struct
  type 'a t = { name : string; age : int; unknown : 'a; }
  let make name age unknown = { name; age; unknown }
  let name p = p.name
  let age p = p.age
  let unknown v = v.unknown
  (* TODO *)
end

Erroring

If the data schema you are modelling is strict you can error on unknown members as follows:

module Person_strict = struct
  type t = { name : string; age : int; }
  let make unknown name age = { name; age }
  let name p = p.name
  let age p = p.age
  let jsont =
    let unknown = Jsont.Obj.Unknown.error in
    Jsont.Obj.map' ~kind:"Person" ~unknown make
    |> Jsont.Obj.mem "name" Jsont.string ~enc:name
    |> Jsont.Obj.mem "age" Jsont.int ~enc:age
    |> Jsont.Obj.finish
end

Dealing with recursive JSON

In order to define recursive values you need to define your description in a lazy expression and use Jsont.rec' to refer to the value you are defining. This results in the following structure:

let jsont : t Jsont.t =
  let rec t = lazy ( … Jsont.rec' t … ) in
  Lazy.force t

For example a tree encoded as a JSON object with:

{ "value": …,
  "children": […] }

Is modelled by:

module Tree = struct
  type 'a t = Node of 'a * 'a t list
  let make v children = Node (v, children)
  let value (Node (v, _)) = v
  let children (Node (_, children)) = children
  let jsont value_type =
    let rec t = lazy begin
      Jsont.Obj.make ~kind:"Tree" make
      |> Jsont.Obj.mem "value" value_type ~enc:value
      |> Jsont.Obj.mem "children" (Jsont.list (Jsont.rec' t)) ~enc:children
      |> Jsont.Obj.finish
    end
    in
    Lazy.force t
end

Dealing with object types classes

Sometimes JSON objects have a distinguished case member, called "type", "class" or "version" whose value define the rest of the object.

The Jsont.Obj.Case module handle this pattern. Each case is described by a Jsont.Obj.map object description and the Jsont.Obj.case_mem allows to chose between them according to the value of the case member.

In OCaml there are two main ways to represent these case objects. Either by an enclosing variant type with one case for each object kind:

type t = C1 of C1.t | C2 of C2.t | …

or with a record which holds common fields an a field that holds the cases:

type type' = C1 of C1.t | C2 of C2.t | …
type t =   { type' : type'; … (* other common fields *) }

From Jsont's perspective there is not much difference.

We show both modellings on a hypothetic Geometry object which has a "name" member and a "type" string case member indicating whether the object is a "Circle" or a "Rect". Except for the position of the name field not much changes in each modelling.

Using an enclosing variant type:

module Geometry_variant = struct
  module Circle = struct
    type t = { name : string; radius : float; }
    let make name radius = { name; radius }
    let name c = c.name
    let radius c = c.radius
    let jsont_obj =
      Jsont.Obj.map ~kind:"Rect" make
      |> Jsont.Obj.mem "name" Jsont.string ~enc:name
      |> Jsont.Obj.mem "radius" Jsont.number ~enc:radius
      (* Note, there is no Jsont.Obj.finish here *)
  end

  module Rect = struct
    type t = { name : string; width : float; height : float }
    let make name width height = { name; width; height }
    let name r = r.name
    let width r = r.width
    let height r = r.height
    let jsont_obj =
      Jsont.Obj.map ~kind:"Rect" make
      |> Jsont.Obj.mem "name" Jsont.string ~enc:name
      |> Jsont.Obj.mem "width" Jsont.number ~enc:width
      |> Jsont.Obj.mem "height" Jsont.number ~enc:height
      (* Note, there is no Jsont.Obj.finish here *)
  end

  type t = Circle of Circle.t | Rect of Rect.t
  let circle c = Circle c
  let rect r = Rect r
  let jsont =
    let circle = Jsont.Obj.Case.make "Circle" Circle.jsont_obj ~dec:circle in
    let rect = Jsont.Obj.Case.make "Rect" Rect.jsont_obj ~dec:rect in
    let enc_case = function
    | Circle c -> Jsont.Obj.Case.value circle c
    | Rect r -> Jsont.Obj.Case.value rect r
    in
    let parts = Jsont.Obj.Case.[part circle; part rect] in
    Jsont.Obj.map ~kind:"Geometry" Fun.id
    |> Jsont.Obj.case_mem "type" Jsont.string ~enc:Fun.id ~enc_case parts
    |> Jsont.Obj.finish
end

Using a record with a type' field:

module Geometry_record = struct
  module Circle = struct
    type t = { radius : float; }
    let make radius = { radius }
    let radius c = c.radius
    let jsont_obj =
      Jsont.Obj.map ~kind:"Circle" make
      |> Jsont.Obj.mem "radius" Jsont.number ~enc:radius
      (* Note, there is no Jsont.Obj.finish here *)
  end

  module Rect = struct
    type t = { width : float; height : float }
    let make width height = { width; height }
    let width r = r.width
    let height r = r.height
    let jsont_obj =
      Jsont.Obj.map ~kind:"Rect" make
      |> Jsont.Obj.mem "width" Jsont.number ~enc:width
      |> Jsont.Obj.mem "height" Jsont.number ~enc:height
      (* Note, there is no Jsont.Obj.finish here *)
  end

  type type' = Circle of Circle.t | Rect of Rect.t
  let circle c = Circle c
  let rect r = Rect r

  type t = { name : string; type' : type' }
  let make name type' = { name; type' }
  let name g = g.name
  let type' g = g.type'
  let jsont =
    let circle = Jsont.Obj.Case.make "Circle" Circle.jsont_obj ~dec:circle in
    let rect = Jsont.Obj.Case.make "Rect" Rect.jsont_obj ~dec:rect in
    let enc_case = function
    | Circle c -> Jsont.Obj.Case.value circle c
    | Rect r -> Jsont.Obj.Case.value rect r
    in
    let parts = Jsont.Obj.Case.[part circle; part rect] in
    Jsont.Obj.map ~kind:"Geometry" make
    |> Jsont.Obj.mem "name" Jsont.string ~enc:name
    |> Jsont.Obj.case_mem "type" Jsont.string ~enc:type' ~enc_case parts
    |> Jsont.Obj.finish
end