In this blog post, we take a first look at the ECMAScript proposal “Record & Tuple” (by Robin Ricard and Rick Button). This proposal adds two kinds of compound primitive values to JavaScript:
At the moment, JavaScript only compares primitive values such as strings by value (by looking at their contents):
> 'abc' === 'abc'
true
In contrast, objects are compared by identity (each object has a unique identity and is only strictly equal to itself):
> {x: 1, y: 4} === {x: 1, y: 4}
false
> ['a', 'b'] === ['a', 'b']
false
> const obj = {x: 1, y: 4};
> obj === obj
true
The proposal Record & Tuple (by Robin Ricard and Rick Button) lets us create compound values that are compared by value.
For, example, by prefixing an object literal with a number sign (#
), we create a record – a compound value that is compared by value and immutable:
> #{x: 1, y: 4} === #{x: 1, y: 4}
true
If we prefix an Array literal with #
, we create a tuple – an Array that is compared by value and immutable:
> #['a', 'b'] === #['a', 'b']
true
Compound values that are compared by value are called compound primitive values or compound primitives.
We can see that records and tuples are primitives when we use typeof
:
> typeof #{x: 1, y: 4}
'record'
> typeof #['a', 'b']
'tuple'
> Record({x: 1, y: 4})
#{x: 1, y: 4}
> Tuple.from(['a', 'b'])
#['a', 'b']
Caveat: These conversions are shallow. If any of the nodes in a tree of values is not primitive, then Record()
and Tuple.from()
will throw an exception.
> Object(#{x: 1, y: 4})
{x: 1, y: 4}
> Array.from(#['a', 'b'])
['a', 'b']
Caveat: These conversions are shallow.
const record = #{x: 1, y: 4};
// Accessing properties
assert.equal(record.y, 4);
// Destructuring
const {x} = record;
assert.equal(x, 1);
// Spreading
assert.ok(
#{...record, x: 3, z: 9} === #{x: 3, y: 4, z: 9});
const tuple = #['a', 'b'];
// Accessing elements
assert.equal(tuple[1], 'b');
// Destructuring (tuples are iterable)
const [a] = tuple;
assert.equal(a, 'a');
// Spreading
assert.ok(
#[...tuple, 'c'] === #['a', 'b', 'c']);
// Updating
assert.ok(
tuple.with(0, 'x') === #['x', 'b']);
Some data structures such as hash maps and search trees have slots in which keys are placed according to their values. If the value of key changes, it generally has to be put in a different slot. That’s why, in JavaScript, values that can be used as keys, are either:
Compound primitives help with:
Deeply comparing objects – which is a built-in operation and can be invoked, e.g., via ===
.
Sharing values: If an object is mutable, we need to deeply copy it if we want to share it safely. With immutable values, sharing is not a problem.
Non-destructive updates of data: We can safely reuse parts of a compound value when we create a modified copy of it (due to everything being immutable).
Using data structures such as Maps and Sets: They become more powerful because two compound primitives with the same content are considered strictly equal everywhere in the language (including keys of Maps and elements of Sets).
The next sections demonstrate these benefits.
With compound primitives, we can eliminate duplicates even though they are compound (and not atomic, like primitive values):
> [...new Set([#[3,4], #[3,4], #[5,-1], #[5,-1]])]
[#[3,4], #[5,-1]]
This does not work with Arrays:
> [...new Set([[3,4], [3,4], [5,-1], [5,-1]])]
[[3,4], [3,4], [5,-1], [5,-1]]
As objects are compared by identity, it rarely makes sense to use them as keys in (non-weak) Maps:
const m = new Map();
m.set({x: 1, y: 4}, 1);
m.set({x: 1, y: 4}, 2);
assert.equal(m.size, 2);
This is different if we use compound primitives: The Map in line A maps addresses (Records) to names.
const persons = [
#{
name: 'Eddie',
address: #{
street: '1313 Mockingbird Lane',
city: 'Mockingbird Heights',
},
},
#{
name: 'Dawn',
address: #{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
},
#{
name: 'Herman',
address: #{
street: '1313 Mockingbird Lane',
city: 'Mockingbird Heights',
},
},
#{
name: 'Joyce',
address: #{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
},
];
const addressToNames = new Map(); // (A)
for (const person of persons) {
if (!addressToNames.has(person.address)) {
addressToNames.set(person.address, new Set());
}
addressToNames.get(person.address).add(person.name);
}
assert.deepEqual(
// Convert the Map to an Array with key-value pairs,
// so that we can compare it via assert.deepEqual().
[...addressToNames],
[
[
#{
street: '1313 Mockingbird Lane',
city: 'Mockingbird Heights',
},
new Set(['Eddie', 'Herman']),
],
[
#{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
new Set(['Dawn', 'Joyce']),
],
]);
In the following example, we use the Array method .filter()
(line B) to extract all entries whose address is equal to address
(line A).
const persons = [
#{
name: 'Eddie',
address: #{
street: '1313 Mockingbird Lane',
city: 'Mockingbird Heights',
},
},
#{
name: 'Dawn',
address: #{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
},
#{
name: 'Herman',
address: #{
street: '1313 Mockingbird Lane',
city: 'Mockingbird Heights',
},
},
#{
name: 'Joyce',
address: #{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
},
];
const address = #{ // (A)
street: '1630 Revello Drive',
city: 'Sunnydale',
};
assert.deepEqual(
persons.filter(p => p.address === address), // (B)
[
#{
name: 'Dawn',
address: #{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
},
#{
name: 'Joyce',
address: #{
street: '1630 Revello Drive',
city: 'Sunnydale',
},
},
]);
Whenever we work with cached data (such previousData
in the following example), the built-in deep equality lets us check efficiently if anything has changed.
let previousData;
function displayData(data) {
if (data === previousData) return;
// ···
}
displayData(#['Hello', 'world']); // displayed
displayData(#['Hello', 'world']); // not displayed
Most testing frameworks support deep equality to check if a computation produces the expected result. For example, the built-in Node.js module assert
has the function deepEqual()
. With compound primitives, we have an alternative to such functionality:
function invert(color) {
return #{
red: 255 - color.red,
green: 255 - color.green,
blue: 255 - color.blue,
};
}
assert.ok(
invert(#{red: 255, green: 153, blue: 51})
=== #{red: 0, green: 102, blue: 204});
Note: Given that built-in equality checks do more than just compare values, it is more likely that they will support compound primitives and be more efficient for them (vs. the checks becoming obsolete).
One downside of the new syntax is that the character #
is already used elsewhere (for private fields) and that non-alphanumeric characters are always slightly cryptic. We can see that here:
const della = #{
name: 'Della',
children: #[
#{
name: 'Huey',
},
#{
name: 'Dewey',
},
#{
name: 'Louie',
},
],
};
The upside is that this syntax is concise. That’s important if a construct is used often and we want to avoid verbosity. Additionally, crypticness is much less of an issue because we get used to the syntax.
Instead of special literal syntax, we could have used factory functions:
const della = Record({
name: 'Della',
children: Tuple([
Record({
name: 'Huey',
}),
Record({
name: 'Dewey',
}),
Record({
name: 'Louie',
}),
]),
});
This syntax could be improved if JavaScript supported Tagged Collection Literals (a proposal by Kat Marchán that she has withdrawn):
const della = Record!{
name: 'Della',
children: Tuple![
Record!{
name: 'Huey',
},
Record!{
name: 'Dewey',
},
Record!{
name: 'Louie',
},
],
};
Alas, even if we use shorter names, the result is still visually cluttered:
const R = Record;
const T = Tuple;
const della = R!{
name: 'Della',
children: T![
R!{
name: 'Huey',
},
R!{
name: 'Dewey',
},
R!{
name: 'Louie',
},
],
};
JSON.stringify()
treats records like objects and tuples like Arrays (recursively).JSON.parseImmutable
works like JSON.parse()
but returns records instead of objects and tuples instead of Arrays (recursively).Instead of plain objects or Arrays, I like to use classes that are often just data containers because they attach names to objects. For that reason, I’m hoping that we’ll eventually get classes whose instances are immutable and compared by value.
It’d be great if we also had support for deeply and non-destructively updating data that contains objects produced by value type classes.