Table of contents for this series of posts: “What is ReasonML?”
This blog post examines how ReasonML’s records work.
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.
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.
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 };
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 } */
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
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};
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
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": "+R"
}
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": "+R",
"error": "+R"
}
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.
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,
});
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),
};
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,
};
};
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};
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));
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;
···
});
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 };
};
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.