Generic immutable objects in JavaScript
In Avers I recently made Editable objects immutable. Whenever Avers
needs to change an Editable, it makes an copy of the object and freezes it.
That way accidental mutations will throw an error.
The easy solution would be to add a clone method to the class. However, I'll
likely have more objects which I will make immutable, so I aimed for a more
generic solution that would work with all classes.
Generic clone function for JavaScript classes
function clone<T>(x: T, f: (x: T) => void): T {
// 1. Create a new instance of the class.
let copy = new x.constructor;
// 2. Copy all instance properties to the copy.
Object.assign(copy, x);
// 3. Apply modifications to the copy.
f(copy);
// 4. Freeze the copy to prevent any future modifications.
Object.freeze(copy);
return copy;
}
This function assumes that the class is a pure data container. That means its constructor either does not take any arguments, or it simply initializes instance properties with those. The class may have functions defined on its prototype, because the prototype is preserved.
class DataContainer {
constructor(foo, bar) {
// OK
this.foo = foo;
this.bar = bar;
// NOT OK
this.baz = foo * bar;
this.fun = function() {
return foo * bar;
}
}
// OK
f(): {
return this.foo * this.bar;
}
}
The callback is allowed to set instance properties. It must not modify the properties in place (eg. push to an array). When a property is not a primitive value, the callback must clone it as well.
clone(obj, copy => {
// BAD: Modify the objects in-place
copy.array.push(1);
copy.nested.foo = 2;
// GOOD: Make a copy of the objects.
copy.array = copy.array.slice(0).push(1);
copy.nested = clone(copy.nested, n => { n.foo = 2; });
// OK to set primitives
copy.string = "blah";
copy.isGood = true;
});
Creating the copy: new x.constructor vs Object.create(x)
In step 1 I use new x.constructor to create the copy. An alternative would be
Object.create. The reason for calling the constructor is to keep the prototype
chain as short as possible. With frequent clones, the prototype chain would
quickly grow very long, potentially impacting performance. There are no
downsides to using the constructor, as it has been around
since JavaScript 1.1.
Pitfalls when working with immutable objects
One easy mistake one can make when working with immutable data is that it's very easy to make (bad) decisions based on outdated information. Especially when you hold onto objects across function boundaries, it's difficult to spot the mistake. Here's a real example from Avers:
function saveEditable(h, obj) {
if (obj.localChanges.length === 0) {
return;
}
sendPatchesToServer(...).then(() => {
modifyEditable(h, obj.objId, obj => {
obj.localChanges = [];
});
// 'obj' is now outdated, its 'localChanges' remains non-empty.
saveEditable(h, obj);
});
}
The symptoms were that Avers kept sending the same patches over and over to
the server. The problem here is that the recursive call to saveEditable is
acting on outdated information, because the modifyEditable function creates
a copy and leaves the original untouched. One has to be very careful to note
when an object becomes outdated.