Record & Tuple types
Immutability is gradually making its way to Javascript. After Temporal API’s embrace of immutability, other immutable types, namely Records & Tuples, are coming to Javascript as well.
Introduction
The ECMAScript proposal for Records & Tuples is a proposal to add native support for records and tuples to the Javascript language. We don’t have native records or tuples in JS now, but, as you will see, we can start to play with them with the Babel plugin and polyfill. So let’s take a look at what records and tuples are.
I’ll start with the record. A record type is a deeply immutable object-like structure. To create a record, use an object literal and prefix it with a hash symbol (#).
// a classic object
const user = { name: "S. Holmes", age: 27 }
const address = { street: "221B Baker Street", city: 'London'}
// a record
const user = #{ name: "S. Holmes", age: 27 }
const address = #{ street: "221B Baker Street", city: 'London'}
Similarly to a record, a tuple is an immutable structure that resembles an array. However, once again its syntax looks like a declaring array with a hash symbol.
// array
const enemies = ["James Moriarty", "Irene Adler"]
const friends = ["G. Lestrade", "John Hamish Watson"]
// tuple
const enemies = #[ "James Moriarty", "Irene Adler"]
const friends = #["G. Lestrade", "John Hamish Watson"]
One thing worth mentioning is that a tuple is actually a record in disguise:
#[10, 25] can be viewed as #{0: 10, 1: 25 }
You can think of tuples as anonymous(aka unlabeled) records. It implies tuples are conceptually more related to records than to arrays.
Compound primitives
The Records & Tuples types are compound primitives with immutable values. Fair enough but what exactly does this clumsy definition mean?
Let’s start with the term primitives. So far, Javascript contains only seven primitive types:
- string
- number
- bigint
- boolean
- undefined
- symbol
- null
All other Javascript types are object types. But what about the term compound? The primitives above are not the compound type since they contain only one value:
// primitive but not compound
const str = "Primitive value"
const number = 1
const bool = true
const n = null
On the other hand, a JS object is a compound value as it consists of multiple values:
// the compound value:
// consists of two primitive values
const object = { name: 'Primitive value', number: 33 }
// consists of two primitive values & one function
const object = { name: 'Primitive value', number: 33, fn: () => {} }
The final part of the definition, deeply immutable, means that all values must be immutable, and their subelements must also be deeply immutable. This rule means that Record & Tuple can contain only primitive values, plus, records or tuples as well.
// record with a nested record
const record = #{ name: 'Primitive value', number: 33,
nestedRecord : #{ number: 50} }
// tuple with a nested tuple and record
const tuple = #[ 'Primitive value', 33, #{ number: 50}, #[1, true, null] ]
// FORBIDDEN usage:
// record with a nested object
// it throws an error as it’s not a primitive value anymore
const record = #{ name: 'Primitive value', number: 33,
nestedObject: { number: 50} }
History
Record & Tuple is not a new concept. Most functional languages have these kinds of types. Plus, multiple mainstream languages like C#, Python, and Java have also recently adopted them. Even in Javascript, there has been prior work done on immutable data structures provided by the Immutable.JS library and others.
Let’s compare immutable.JS’ records vs. the new upcoming native record’s syntax:
//Immutable.JS
const record1 = Record({ a: 1, b: 2 })
const record2 = record1.set('b', 50)
const b = record2.get('b')
const recordA = Record({ a: 1, b: 2 })
const recordB = Record({ a: 1, b: 2 })
recordA.equals(recordB) // true
recordA == (recordB) // false !!!
// Native records
const recordNative = #{ a: 1, b: 2 }
const recordNative2 = #{ ...recordNative, b: 50 }
const b = recordNative2.b
const recordA = #{ a: 1, b: 2 }
const recordB = #{ a: 1, b: 2 }
recordA == (recordB) // true !!!
The new hash syntax seems pretty handy and proves that proper language support is key to providing a pleasant developer experience. Using Immutable.JS, you must learn a new API and work with string keys to have immutable values. At the same time, working with native records is similar to working with good old objects — the developer experience at its best.
Please, don’t get me wrong, I’m not criticizing Immutable.JS’s way of working. Not at all. Immutable.JS has been doing a great job in popularizing the concept of immutable types in Javascript. Still, I think it’s time to look forward to having the native immutable types in our toolbelt.
Main features of Record & Tuple
Now let’s look at the main features of the new types.
Equality
Equality has always been a tricky issue. Using classic objects, you have to rely solely on referential equality:
// Identity equality
const user = { name: 'Dr. Watson'}
const userRef = user
userRef == user // => true
const user = { name: 'Dr. Watson'}
const user2 = { name: 'Dr. Watson'}
user2 == user // => false
On the other hand, records are primitive types, so record logically uses values to determine their equality:
// Value equality
const user = #{ name: 'Dr. Watson'}
const user2 = #{ name: 'Dr. Watson'}
user2 == user // => true
One thing to remember is that the order of fields in records doesn’t matter:
const user1 = #{ name: 'S. Holmes', age: 27 }
const user2 = #{ age: 27, name: 'S. Holmes'}
user1 == user2 // => true
This record’s behavior means we don’t need to worry whether the particular record has been updated or where it has been created. Two records are equal if they have identical values.
The situation regarding tuples is a bit different. This code works as expected:
const friends1 = #['G. Lestrade', 'John Hamish Watson']
const friends2 = #['G. Lestrade', 'John Hamish Watson']
friends1 == friends2 // => true
However, the following code doesn’t:
const friends1 = #['G. Lestrade', 'John Hamish Watson']
const friends2 = #['John Hamish Watson', 'G. Lestrade']
friends1 == friends2 // => false
Contrary to records, tuples are an ordered collection, so the order of items matters.
Immutability
Immutable structures have many benefits over their mutable counterparts. In the Javascript context, the immutable structures help us prevent accidental modification and make our codebase easier to reason about.
An example of immutability:
const primitive = 'string is an immutable primitive'
const primitive2 = primitive + '— change it'
primitive == primitive2 // => false
const obj1 = { id: 'A', value: 1}
const obj2 = obj1
// the following code changes the value of obj2.value
// but it also change obj1.value
obj2.value = 2
obj1 === obj2 // => still true
obj1.value === obj2.value // => true
// But with immutability, you can't change the record like a normal object.
const record1 = #{ id: 'A', value: 1}
record1.value = 2 // throws error
// You need to be more explicit to get changed value
const record2 = #{ ... record1, value: 2}
And described explicitness(creating record2) saves us from many headaches.
Working with Record & Tuple
So far, I’ve shown you the syntax for creating literal tuples or records. However, the new syntax supports more advanced use cases.
Create a modified instance using spread
You can modify your instances using a familiar spread operator “…”. It’s similar to the with method for Temporal objects.
Example of spread operator:
const user = #{ name: 'S. Holmes', age: 27 }
// with immutable types, you are restricted to changing value directly:
user.age = 28 // throws error
// With spread operator, you can copy a record
// and modify it at the same time:
const userOneYearOlder = #{ ...user, age: 28}
// => #{ name: 'S. Holmes', get: 28 }
// Of course, it also means:
user == userOneYearOlder // => false
// Tuple
const friends1 = #['G. Lestrade', 'Dr. Watson']
const friends2 = #[...friends1, 'Mrs Hudson']
// => #['G. Lestrade', 'Dr. Watson', 'Mrs Hudson']
// You can modify an item at a particular position with the method 'with'
const friends3 = friends1.with(0, 'Mrs Hudson')
// => #['Mrs Hudson', 'Dr. Watson']
Tuples also provide a couple of helper methods.
const friends1 = #['Dr. Watson', 'G. Lestrade']
const friends2 = friends1.toReversed()
// => ['G. Lestrade', 'Dr. Watson']
const friends3 = #['G. Lestrade', 'Dr. Watson']
const friends4 = friends3.toSorted()
// friends4 == #['Dr. Watson', 'G. Lestrade']
Note: neither the method toSorted nor toReversed changes the original instance.
Converting arrays to tuples and objects to records
It’s easy to convert instances between immutable types and their counterparts:
const record = #{ name: 'S. Holmes', age: 27 }
// Note that any iterable of entries will work
const record2 = Record.fromEntries([['age', 27], #['name', 'S. Holmes']])
const tuple = Tuple(...[1, 2, 3])
// Note that an iterable will work as well
const tuple2 = Tuple.from([1, 2, 3])
record == #{ name: 'S. Holmes', age: 27 } // => true
tuple == #[1, 2, 3] // => true
// JS Compiler throws an error when using a non-primitive value
// TypeError: cannot use an object as a value in a record
Record({ a: {} })
// TypeError: cannot use an object as a value in a record
Tuple.from([{}, {} , {}])
Conversion back to object/array is simple:
const obj = { ...record }
const arr = [ ...tuple ]
You can see more examples in Record & Tuple Cookbook.
Should we adopt Record & Tuple?
I’d say yes, without hesitation. I’ve summed up my arguments below.
Familiarity
I like the idea mentioned in the book Kill with it Fire by Marriane Bellotti:
The adoption of new technology is driven by familiarity over superiority.
Marriane’s concept fits here nicely. The authors of the Tuple & Record proposal tried to mimic the semantics of object and array to help us adopt these new primitive types. As a result, the record behaves similarly to objects or arrays. Thus, the adoption should be fast.
Modeling our business domain
The immutability and comparison shine when modeling the domain. Business application developers usually employ numerous domain classes representing Users, Invoices, Reports, and other real-world objects. In these cases, records might stand in good stead.
But before the OOP zealots take-up their pitchforks, let me clarify: I know that using records for domain classes should lead to an anemic domain model — OOP anti-pattern. Nevertheless, it is also possible to define behaviors for domain concepts in a programming language using higher-order functions or other functional techniques. Feel free to read more about this idea in “Are functional domain models always anemic” or “The Anaemic Domain Model is no Anti-Pattern, it’s a SOLID design.”
Ultimately, I tried both the classic and anemic domain models, and the former suited me better, even in strict OOP language.
What about Tuples?
Whereas records are fit for modeling your domain classes, tuples have a more specialized usage. Tuples provide an easy way to return or pass multiple values from or to a function. Javascript developers, especially React developers, have been using tuples a lot after the deconstruction assignment landed in Javascript.
React’s useState hook illustrated it perfectly:
// [count, setCount] is a classic tuple-like array
const [count, setCount] = useState(0)
You can imagine that [count, setCount] might be an ideal candidate to be a native Javascript tuple in the future.
Another use case for tuples is the memoization pattern. Now, without native tuples, you need to use tricks to serialize the function’s arguments, such as JSON.stringify:
const memoized = new Map()
function compute(...args) {
// a hack
const key = JSON.stringify({...args})
if (memoized.has(key))
{
return memoized.get(key)
}
else {
const value = peformSlowComputation(...args)
memoized[key] = value
return value
}
}
However, with tuples, you can write cleaner code:
const memoized = new Map()
function compute(... args) {
// a nice and clean key
const key = #[...args]
if (memoized.has(key ))
{
return memoized.get(key)
}
else {
const value = performSlowComputation(...args)
memoized[key] = value
return value
}
}
Instead of JSON.stringify, you can just use #[…args] and have a handy key for your cache.
I must also warn you, please use tuples carefully. Sometimes your use case might look perfectly fine for using a tuple. Ultimately though, it may bring more confusion. For example, geographic coordinates:
const [lat, long] = getCoordinates()
VS.
const [long, lat] = getCoordinates()
How do you know which one is correct? Even typescript doesn’t help you here. Better version with a record:
// it works in both cases:
const {lat,long} = getCoordinates()
const {long,lat} = getCoordinates()
Plus, if tuples have more than a few elements, it’s easy to make mistakes using the wrong tuple member. In both cases, I recommend using records over tuples.
Optimization
Not only do immutable structures provide cleaner code, but they also have hidden performance effects. Because JS is a dynamic language, the JS compiler has to make many guesses about the object’s memory layout. The fact that you can add a new object’s field at any time complicates things. Static languages offer better optimization because they know the objects’ shape in advance. To learn more, read the following article on function optimization in V8. With immutable types, authors of Javascript compilers may be able to optimize them, similarly to static typing.
Status
As for January 2023, the Record & Tuple are still in stage 2, which means they won’t land anytime soon in our browsers. Yet, with this working polyfill and Babel plugin, you can start exploring possible use cases. Or you can play with these new types in this playground.
Conclusion
Record & Tuple will be another big thing in Javascript. Admittedly, it’s less useful at first sight than the new Temporal API or fancy API like WebXR. Still, it brings another popular concept from functional languages, which will definitely make our coding easier.
We are ACTUM Digital and this piece was written by Marek, Senior Front End Developer of Apollo Division.
If you seek help with your project or initiative, just drop us a line. 🚀