A first look at records and tuples in JavaScript

[2020-05-26] dev, javascript, es proposal
(Ad, please don’t block)

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:

  • Records, immutable compared-by-value versions of plain objects
  • Tuples, immutable compared-by-value versions of Arrays

Comparing by value  

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.

Records and tuples are primitives  

We can see that records and tuples are primitives when we use typeof:

> typeof #{x: 1, y: 4}
'record'
> typeof #['a', 'b']
'tuple'

Restrictions of what can be inside records and tuples  

  • Records:
    • Keys must be strings.
    • Values must be primitives (including records and tuples).
  • Tuples.
    • Elements must be primitives (including records and tuples).

Converting objects to records and tuples  

> 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.

Converting records and tuples to objects  

> Object(#{x: 1, y: 4})
{x: 1, y: 4}
> Array.from(#['a', 'b'])
['a', 'b']

Caveat: These conversions are shallow.

Working with records  

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});

Working with tuples  

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']);

Why are values that are compared by value immutable in JavaScript?  

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:

  • Compared by value and immutable (primitives)
  • Compared by identity and potentially mutable (objects)

Benefits of compound primitives  

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.

Examples: Sets and Maps become more useful  

Eliminating duplicates via Sets  

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]]

Compound keys in Maps  

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']),
    ],
  ]);

Examples: efficient deep equals  

Processing objects with compound property values  

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',
      },
    },
  ]);

Has an object changed?  

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

Testing  

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).

The pros and cons of the new syntax  

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 and records and tuples  

  • 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).

Future: classes whose instances are compared by value?  

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.

Acknowledgements  

Further reading