ReasonML: records

[2018-01-11] dev, reasonml
(Ad, please don’t block)

Table of contents for this series of posts: “What is ReasonML?


This blog post examines how ReasonML’s records work.

What are records?  

A record is similar to a tuple: it has a fixed size and each of its parts can have a different type and is accessed directly. However, where the parts of a tuple (its components) are accessed by position, the parts of a record (its fields) are accessed by name. By default, records are immutable.

Basic use  

Defining record types  

Before you can create a record, you must define a type for it. For example:

type point = {
  x: int,
  y: int, /* optional trailing comma */
};

We have defined the record type point which has two fields, x and y. Names of fields must start with lowercase letters.

Within the same scope, no two records should have the same field name. The reason for this restrictive rule is that field names are used to determine the type of a record. In order to achieve this task, each field name is associated with exactly one record type.

It is possible to use the same field name in more than one record, but then usability suffers: The last record type with a field name “wins” w.r.t. type inference. As a consequence, using the other record types becomes more complicated. So I prefer to pretend reusing field names isn’t possible.

We’ll examine how to work around this limitation later on.

Nesting record types  

Is it possible to nest record types? For example, can we do the following?

type t = { a: int, b: { c: int }};

No we can’t. We’d get a syntax error. This is how to properly define t:

type b = { c: int };
type t = { a: int, b: b };

With b: b, field name and field value are the same. Then you can abbreviate both as just b. That is called punning:

type t = { a: int, b };

Creating records from scratch  

This is how you create a record from scratch:

# let pt1 = { x: 12, y: -2 };
let pt1: point = {x: 12, y: -2};

Note how the field names were used to infer that pt1 has the type point.

Punning works here, too:

let x = 7;
let y = 8;

let pt2 = {x, y};
  /* Same as: { x: x, y: y } */

Accessing field values  

Field values are accessed via the dot (.) operator:

# let pt = {x: 1, y: 2};
let pt: point = {x: 1, y: 2};
# pt.x;
- : int = 1
# pt.y;
- : int = 2

Non-destructive updates of records  

Records are immutable. To change the value of a field f of a record r, we must create a new record s. s.f has a new value, all other fields of s have the same values as in r. That is achieved via the following syntax:

let s = {...r, f: newValue}

The triple dots (...) are called the spread operator. They must come first and can be used at most once. However, you can update more than one field (not just a single field f).

This is an example of using the spread operator:

# let pt = {x: 1, y: 2};
let pt: point = {x: 1, y: 2};
# let pt' = {...pt, y: 3};
let pt': point = {x: 1, y: 3};

Pattern matching  

All the usual pattern matching mechanisms work with records, too. For example:

let isOrig = (pt: point) =>
  switch pt {
  | {x: 0, y: 0} => true
  | _ => false
  };

This is what destructuring via let looks like:

# let pt = {x: 1, y: 2};
let pt: point = {x: 1, y: 2};

# let {x: xCoord} = pt;
let xCoord: int = 1;

You can use punning:

# let {x} = pt;
let x: int = 1;

Destructuring of parameters works, too:

# let getX = ({x}) => x;
let getX: (point) => int = <fun>;
# getX(pt);
- : int = 1

Warning about missing fields  

During pattern matching, you can omit all fields you are not interested in, by default. For example:

type point = {
  x: int,
  y: int,
};

let getX = ({x}) => x; /* Don’t do this */

For getX(), we are not interested in the field y and only mention field x. However, it’s better to be explicit about omitting fields:

let getX = ({x, _}) => x;

The underscore after x tells ReasonML: we are ignoring all remaining fields.

Why is it better to be explicit? Because now you can let ReasonML warn you about missing field names, by adding the following entry to bsconfig.json:

"warnings": {
  "number": "+9"
}

The initial version now triggers the following warning:

Warning number 9

4 │ };
5 │
6 │ let getX = ({x}) => x;

the following labels are not bound in this record pattern:
y
Either bind these labels explicitly or add '; _' to the pattern.

I recommend to go even further and make missing fields an error (compilation doesn’t finish):

"warnings": {
  "number": "+9",
  "error": "+9"
}

Consult the BuckleScript manual for more information on configuring warnings.

Checking for missing fields is especially important for code that uses all current fields:

let string_of_point = ({x, y}: point) =>
  "(" ++ string_of_int(x) ++ ", "
  ++ string_of_int(y) ++ ")";

string_of_point({x:1, y:2});
  /* "(1, 2)" */

If you were to add another field to point (say, z) then you want ReasonML to warn you about string_of_point, so that you can update it.

Recursive record types  

Variants were the first example that we have seen of recursively defined types. You can use records in recursive definitions, too. For example:

type intTree =
  | Empty
  | Node(intTreeNode)
and intTreeNode = {
  value: int,
  left: intTree,
  right: intTree,
};

The variant intTree recursively relies on the definition of the record type intTreeNode. This is how you create elements of type intTree:

let t = Node({
  value: 1,
  left: Node({
    value: 2,
    left: Empty,
    right: Node({
      value: 3,
      left: Empty,
      right: Empty,
    }),
  }),
  right: Empty,
});

Parameterized record types  

In ReasonML, types can be parameterized by type variables. You can use those type variables when defining record types. For example, if we want trees to contain arbitrary values, not just ints, we make the type of the field value polymorphic (line A):

type tree('a) =
  | Empty
  | Node(treeNode('a))
and treeNode('a) = {
  value: 'a, /* A */
  left: tree('a),
  right: tree('a),
};

Records in other modules  

Each record is defined within a scope (e.g. a module). Its field names exist at the top level of that scope. While that helps with type inference, it makes using field names more complicated than in many other languages. Let’s see how various record-related mechanisms are affected if we put point into another module, M:

module M = {
  type point = {
    x: int,
    y: int,
  };
};

Creating records from other modules  

If we try to create a record of type point as if that type were in the same scope, we fail:

let pt = {x: 3, y: 2};
  /* Error: Unbound record field x */

The reason is that x and y don’t exist as names within the current scope, they only exist within module M.

One way to fix this is by qualifying at least one of the field names:

let pt1 = {M.x: 3, M.y: 2}; /* OK */
let pt2 = {M.x: 3, y: 2}; /* OK */
let pt3 = {x: 3, M.y: 2}; /* OK */

Another way to fix this is by qualifying the whole record. It is interesting how ReasonML reports the inferred type – both the type and the first field name are qualified:

# let pt4 = M.{x: 3, y: 2};
let pt4: M.point = {M.x: 3, y: 2};

Lastly, you can also open M and therefore import x and y into the current scope.

open M;
let pt = {x: 3, y: 2};

Accessing fields from other modules  

If you don’t open M, you can’t use unqualified names to access fields:

let pt = M.{x: 3, y: 2};

print_int(pt.x);
/*
Warning 40: x was selected from type M.point.
It is not visible in the current scope, and will not
be selected if the type becomes unknown.
*/

The warning goes away if you qualify the field name x:

print_int(pt.M.x); /* OK */

Locally opening M works, too:

M.(print_int(pt.x));
print_int(M.(pt.x));

Pattern matching and records from other modules  

With pattern matching, you face the same issue as with accessing fields normally – you can’t use the field names of point without qualifying them:

# let {x, _} = pt;
Error: Unbound record field x

If we qualify x, everything is OK:

# let {M.x, _} = pt;
let x: int = 3;

Alas, qualifying the pattern doesn’t work:

# let M.{x, _} = pt;
Error: Syntax error

We can locally open M for the whole let binding. However, it isn’t an expression, which prevents us from wrapping it in parentheses. We must additionally wrap it in a code block (curly braces):

M.({
  let {x, _} = pt;
  ···
});

Using the same field name in multiple records  

I initially promised that we’d be able to use the same field name in multiple records. The trick for doing so is putting each record in a separate module. For example, here are two record type, Person.t and Plant.t that both have the field name. But they reside in separate modules and the name clash is not a problem:

module Person = {
  type t = { name: string, age: int };
};
module Plant = {
  type t = { name: string, edible: bool };
};

FAQ: records  

Is there a way to dynamically specify the name of a field?  

In JavaScript, there are two ways in which you can access a field (called property in JavaScript):

// Static field name (known at compile time)
console.log(obj.prop);

function f(obj, fieldName) {
  // Dynamic field name (known at runtime)
  console.log(obj[fieldName]);
}

In ReasonML, field names are always static. JavaScript objects play two roles: they are both records and dictionaries. In ReasonML, use records if you need a record, use Maps if you need a dictionary.