Portrait Dr. Axel Rauschmayer
Dr. Axel Rauschmayer
Homepage | Twitter
Cover of book “Exploring ES6”
Book, exercises, quizzes
(free to read online)
Logo of newsletter “ES.next news”
Newsletter (free)
Cover of book “JavaScript for impatient programmers”
Book (free online)

The problems of shared mutable state and how to avoid them

[2019-10-21] dev, javascript, deepjs
(Ad, please don’t block)

This blog post answers the following questions:

  • What is shared mutable state?
  • Why is it problematic?
  • How can its problems be avoided?

Sections marked with “(advanced)” go deeper and can be skipped if you want to read this blog post more quickly.


Table of contents:


What is shared mutable state and why is it problematic?  

Shared mutable state works as follows:

  • If two or more parties can change the same data (variables, objects, etc.) and
  • if their lifetimes overlap,

then there is a risk of one party’s modifications preventing other parties from working correctly. This is an example:

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'

Here, there are two independent parties: function logElements() and function main(). The latter wants to log an Array before and after sorting it. However, it uses logElements(), which clears its parameter. Therefore, main() logs an empty Array in line A.

In the remainder of this post, we look at three ways of avoiding the problems of shared mutable state:

  • Avoiding sharing by copying data
  • Avoiding mutations by updating non-destructively
  • Preventing mutations by making data immutable

In particular, we will come back to the example that we’ve just seen and fix it.

Avoiding sharing by copying data  

Before we can get into how copying avoids sharing, we need to take a look at how data can be copied in JavaScript.

Shallow copying vs. deep copying  

There are two “depths” with which data can be copied:

  • Shallow copying only copies the top-level entries of objects and Arrays. The entry values are still the same in original and copy.
  • Deep copying also copies the entries of the values of the entries, etc. That is, it traverses the complete tree whose root is the value to be copied and makes copies of all nodes.

The next sections cover both kinds of copying. Unfortunately, JavaScript only has built-in support for shallow copying. If we need deep copying, we need to implement it ourselves.

Shallow copying in JavaScript  

Let’s look at several ways of shallowly copying data.

Copying plain objects and Arrays via spreading  

We can spread into object literals and spread into Array literals to make copies:

const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];

Alas, spreading has several limitations:

  • The prototype is not copied:

    class MyClass {}
    
    const original = new MyClass();
    assert.equal(MyClass.prototype.isPrototypeOf(original), true);
    
    const copy = {...original};
    assert.equal(MyClass.prototype.isPrototypeOf(copy), false);
    
  • Special objects such as regular expressions and dates have special “internal slots” that aren’t copied.

  • Only own (non-inherited) properties are copied. Given how prototype chains work, this is usually the best approach. But you still need to be aware of it. In the following example, the inherited property .inheritedProp of original is not available in copy, because we only copy own properties and don’t keep the prototype.

    const proto = { inheritedProp: 'a' };
    const original = {__proto__: proto, ownProp: 'b' };
    assert.equal(original.inheritedProp, 'a');
    assert.equal(original.ownProp, 'b');
    
    const copy = {...original};
    assert.equal(copy.inheritedProp, undefined);
    assert.equal(copy.ownProp, 'b');
    
  • Only enumerable properties are copied. For example, the own property .length of Array instances is not enumerable and not copied:

    const arr = ['a', 'b'];
    assert.equal(arr.length, 2);
    assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
    
    const copy = {...arr};
    assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
    
  • Independently of the attributes of a property, its copy will always be a data property that is writable and configurable – for example:

    const original = Object.defineProperties({}, {
      prop: {
        value: 1,
        writable: false,
        configurable: false,
        enumerable: true,
      },
    });
    assert.deepEqual(original, {prop: 1});
    
    const copy = {...original};
    // Attributes `writable` and `configurable` of copy are different:
    assert.deepEqual(Object.getOwnPropertyDescriptors(copy), {
      prop: {
        value: 1,
        writable: true,
        configurable: true,
        enumerable: true,
      },
    });
    

    That means that getters and setters are not copied faithfully, either: The attributes value (for data properties), get (for getters), and set (for setters) are mutually exclusive.

    const original = {
      get myGetter() { return 123 },
      set mySetter(x) {},
    };
    assert.deepEqual({...original}, {
      myGetter: 123, // not a getter anymore!
      mySetter: undefined,
    });
    
  • Copying is shallow: The copy has fresh versions of each key-value entry in the original, but the values of the original are not copied themselves. For example:

    const original = {name: 'Jane', work: {employer: 'Acme'}};
    const copy = {...original};
    
    // Property .name is a copy
    copy.name = 'John';
    assert.deepEqual(original,
      {name: 'Jane', work: {employer: 'Acme'}});
    assert.deepEqual(copy,
      {name: 'John', work: {employer: 'Acme'}});
    
    // The value of .work is shared
    copy.work.employer = 'Spectre';
    assert.deepEqual(
      original, {name: 'Jane', work: {employer: 'Spectre'}});
    assert.deepEqual(
      copy, {name: 'John', work: {employer: 'Spectre'}});
    

Some of these limitations can be eliminated, others can’t:

  • We can give the copy the same prototype as the original during copying:

    class MyClass {}
    
    const original = new MyClass();
    
    const copy = {
      __proto__: Object.getPrototypeOf(original),
      ...original,
    };
    assert.equal(MyClass.prototype.isPrototypeOf(copy), true);
    

    Alternatively, we can set the prototype of the copy after its creation, via Object.setPrototypeOf().

  • There is no simple way to generically copy special objects.

  • As mentioned, only own properties being copied is more of a feature than a limitation.

  • We can use Object.getOwnPropertyDescriptors() and Object.defineProperties() to copy objects (how to do that is explained later):

    • They consider all attributes (not just value) and therefore correctly copy getters, setters, read-only properties, etc.
    • Object.getOwnPropertyDescriptors() retrieves both enumerable and non-enumerable properties.
  • We’ll look at deep copying later in this post.

Shallow copying via Object.assign() (advanced)  

Object.assign() works mostly like spreading into objects. That is, the following two ways of copying are mostly equivalent:

const copy1 = {...original};
const copy2 = Object.assign({}, original);

Using a method instead of syntax has the benefit that it can be polyfilled on older JavaScript engines via a library.

Object.assign() is not completely like spreading, though. It differs in one, relatively subtle point: it creates properties differently.

  • Object.assign() uses assignment to create the properties of the copy.
  • Spreading defines new properties in the copy.

Among other things, assignment invokes own and inherited setters, while definition doesn’t (more information on assignment vs. definition). This difference is rarely noticeable. The following code is an example, but it’s contrived:

const original = {['__proto__']: null};
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
  Object.keys(copy1), ['__proto__']);

const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);

Shallow copying via Object.getOwnPropertyDescriptors() and Object.defineProperties() (advanced)  

JavaScript lets us create properties via property descriptors, objects that specify property attributes. For example, via the Object.defineProperties(), which we have already seen in action. If we combine that method with Object.getOwnPropertyDescriptors(), we can copy more faithfully:

function copyAllOwnProperties(original) {
  return Object.defineProperties(
    {}, Object.getOwnPropertyDescriptors(original));
}

That eliminates two limitations of copying objects via spreading.

First, all attributes of own properties are copied correctly. Therefore, we can now copy own getters and own setters:

const original = {
  get myGetter() { return 123 },
  set mySetter(x) {},
};
assert.deepEqual(copyAllOwnProperties(original), original);

Second, thanks to Object.getOwnPropertyDescriptors(), non-enumerable properties are copied, too:

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

Deep copying in JavaScript  

Now it is time to tackle deep copying. First, we will deep-copy manually, then we’ll examine generic approaches.

Manual deep copying via nested spreading  

If we nest spreading, we get deep copies:

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};

// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);

Hack: generic deep copying via JSON  

This is a hack, but, in a pinch, it provides a quick solution: In order to deep-copy an object original, we first convert it to a JSON string and the parse that JSON string:

function jsonDeepCopy(original) {
  return JSON.parse(JSON.stringify(original));
}
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = jsonDeepCopy(original);
assert.deepEqual(original, copy);

The significant downside of this approach is that we can only copy properties with keys and values that are supported by JSON.

Some unsupported keys and values are simply ignored:

assert.deepEqual(
  jsonDeepCopy({
    [Symbol('a')]: 'abc',
    b: function () {},
    c: undefined,
  }),
  {} // empty object
);

Others cause exceptions:

assert.throws(
  () => jsonDeepCopy({a: 123n}),
  /^TypeError: Do not know how to serialize a BigInt$/);

Implementing generic deep copying  

The following function generically deep-copies a value original:

function deepCopy(original) {
  if (Array.isArray(original)) {
    const copy = [];
    for (const [index, value] of original.entries()) {
      copy[index] = deepCopy(value);
    }
    return copy;
  } else if (typeof original === 'object' && original !== null) {
    const copy = {};
    for (const [key, value] of Object.entries(original)) {
      copy[key] = deepCopy(value);
    }
    return copy;
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

The function handles three cases:

  • If original is an Array we create a new Array and deep-copy the elements of original into it.
  • If original is an object, we use a similar approach.
  • If original is a primitive value, we don’t have to do anything.

Let’s try out deepCopy():

const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);

// Are copy and original deeply equal?
assert.deepEqual(copy, original);

// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy     !== original);
assert.ok(copy.b   !== original.b);
assert.ok(copy.b.d !== original.b.d);

Note that deepCopy() only fixes one issue of spreading: shallow copying. All others remain: prototypes are not copied, special objects are only partially copied, non-enumerable properties are ignored, most property attributes are ignored.

Implementing copying completely generically is generally impossible: Not all data is a tree, sometimes you don’t want to all properties, etc.

A more concise version of deepCopy()  

We can make our previous implementation of deepCopy() more concise if we use .map() and Object.fromEntries():

function deepCopy(original) {
  if (Array.isArray(original)) {
    return original.map(elem => deepCopy(elem));
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original)
        .map(([k, v]) => [k, deepCopy(v)]));
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

Implementing deep copying in classes (advanced)  

Two techniques are often used to implement deep copying for instances of classes:

  • .clone() methods
  • Copy constructors
.clone() methods  

This technique introduces one method .clone() per class whose instances are to be deep-copied. It returns a deep copy of this. The following example shows three classes that can be cloned.

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  clone() {
    return new Point(this.x, this.y);
  }
}
class Color {
  constructor(name) {
    this.name = name;
  }
  clone() {
    return new Color(this.name);
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  clone() {
    return new ColorPoint(
      this.x, this.y, this.color.clone()); // (A)
  }
}

Line A demonstrates an important aspect of this technique: compound instance property values must also be cloned, recursively.

Static factory methods  

A copy constructor is a constructor that uses another instance of the current class to set up the current instance. Copy constructors are popular in static languages such as C++ and Java, where you can provide multiple versions of a constructor via static overloading (static meaning that it happens at compile time).

In JavaScript, you could do something like this (but it’s not very elegant):

class Point {
  constructor(...args) {
    if (args[0] instanceof Point) {
      // Copy constructor
      const [other] = args;
      this.x = other.x;
      this.y = other.y;
    } else {
      const [x, y] = args;
      this.x = x;
      this.y = y;
    }
  }
}

This is how you’d use this class:

const original = new Point(-1, 4);
const copy = new Point(original);
assert.deepEqual(copy, original);

Instead, static factory methods work better in JavaScript (static meaning that they are class methods).

In the following example, the three classes Point, Color and ColorPoint each have a static factory method .from():

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  static from(other) {
    return new Point(other.x, other.y);
  }
}
class Color {
  constructor(name) {
    this.name = name;
  }
  static from(other) {
    return new Color(other.name);
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  static from(other) {
    return new ColorPoint(
      other.x, other.y, Color.from(other.color)); // (A)
  }
}

In line A, we once again use recursive copying.

This is how ColorPoint.from() works:

const original = new ColorPoint(-1, 4, new Color('red'));
const copy = ColorPoint.from(original);
assert.deepEqual(copy, original);

How does copying help with shared mutable state?  

As long as we only read from shared state, we don’t have any problems. Before we modify it, we need to “un-share” it, by copying it (as deeply as necessary).

Defensive copying is a technique to always copy when issues might arise. Its objective is to keep the current entity (function, class, etc.) safe:

  • Input: Copying (potentially) shared data passed to us, lets us use that data without being disturbed by an external entity.
  • Output: Copying internal data before exposing it to an outside party, means that that party can’t disrupt our internal activity.

Note that these measures protect us from other parties, but they also protect other parties from us.

The next sections illustrate both kinds of defensive copying.

Copying shared input  

Remember that in the motivating example at the beginning of this post, we got into trouble because logElements() modified its parameter arr:

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

Let’s add defensive copying to this function:

function logElements(arr) {
  arr = [...arr]; // defensive copy
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

Now logElements() doesn’t cause problems anymore, if it is called inside main():

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'

Copying exposed internal data  

Let’s start with a class StringBuilder that doesn’t copy internal data it exposes (line A):

class StringBuilder {
  constructor() {
    this._data = [];
  }
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // We expose internals without copying them:
    return this._data; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

As long as .getParts() isn’t used, everything works well:

const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');

If, however, the result of .getParts() is changed (line A), then the StringBuilder ceases to work correctly:

const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK

The solution is to copy the internal ._data defensively before it is exposed (line A):

class StringBuilder {
  constructor() {
    this._data = [];
  }
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // Copy defensively
    return [...this._data]; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

Now changing the result of .getParts() doesn’t interfere with the operation of sb anymore:

const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK

Avoiding mutations by updating non-destructively  

We will first explore the difference between updating data destructively and non-destructively. Then we’ll learn how non-destructive updates avoid mutations.

Background: Destructive updates vs. non-destructive updates  

We can distinguish two different ways of updating data:

  • A destructive update of data mutates the data so that it has the desired form.
  • A non-destructive update of data creates a copy of the data that has the desired form.

The latter way is similar to first making a copy and then changing it destructively, but it does both at the same time.

Examples: updating an object destructively and non-destructively  

This is how we destructively set the property .city of an object:

const obj = {city: 'Berlin', country: 'Germany'};
const key = 'city';
obj[key] = 'Munich';
assert.deepEqual(obj, {city: 'Munich', country: 'Germany'});

The following function non-destructively changes properties:

function setObjectNonDestructively(obj, key, value) {
  const updatedObj = {};
  for (const [k, v] of Object.entries(obj)) {
    updatedObj[k] = (k === key ? value : v);
  }
  return updatedObj;
}

It is used as follows:

const obj = {city: 'Berlin', country: 'Germany'};
const updatedObj = setObjectNonDestructively(obj, 'city', 'Munich');
assert.deepEqual(updatedObj, {city: 'Munich', country: 'Germany'});
assert.deepEqual(obj, {city: 'Berlin', country: 'Germany'});

Spreading makes setObjectNonDestructively() more concise:

function setObjectNonDestructively(obj, key, value) {
  return {...obj, [key]: value};
}

Note: Both versions of setObjectNonDestructively() update shallowly.

Examples: updating an Array destructively and non-destructively  

This is how we destructively set an element of an Array:

const original = ['a', 'b', 'c', 'd', 'e'];
original[2] = 'x';
assert.deepEqual(original, ['a', 'b', 'x', 'd', 'e']);

Non-destructively updating an Array is more complicated than non-destructively updating an object.

function setArrayNonDestructively(arr, index, value) {
  const updatedArr = [];
  for (const [i, v] of arr.entries()) {
    updatedArr.push(i === index ? value : v);
  }
  return updatedArr;
}

const arr = ['a', 'b', 'c', 'd', 'e'];
const updatedArr = setArrayNonDestructively(arr, 2, 'x');
assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);

.slice() and spreading make setArrayNonDestructively() more concise:

function setArrayNonDestructively(arr, index, value) {
  return [
  ...arr.slice(0, index), value, ...arr.slice(index+1)]
}

Note: Both versions of setArrayNonDestructively() update shallowly.

Manual deep updating  

So far, we have only updated data shallowly. Let’s tackle deep updating. The following code shows how to do it manually. We are changing name and employer.

const original = {name: 'Jane', work: {employer: 'Acme'}};
const updatedOriginal = {
  ...original,
  name: 'John',
  work: {
    ...original.work,
    employer: 'Spectre'
  },
};

assert.deepEqual(
  original, {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(
  updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});

Implementing generic deep updating  

The following function implements generic deep updating.

function deepUpdate(original, keys, value) {
  if (keys.length === 0) {
    return value;
  }
  const currentKey = keys[0];
  if (Array.isArray(original)) {
    return original.map(
      (v, index) => index === currentKey
        ? deepUpdate(v, keys.slice(1), value) // (A)
        : v); // (B)
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original).map(
        (keyValuePair) => {
          const [k,v] = keyValuePair;
          if (k === currentKey) {
            return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
          } else {
            return keyValuePair; // (D)
          }
        }));
  } else {
    // Primitive value
    return original;
  }
}

If we see value as the root of a tree that we are updating, then deepUpdate() only deeply changes a single branch (line A and C). All other branches are copied shallowly (line B and D).

This is what using deepUpdate() looks like:

const original = {name: 'Jane', work: {employer: 'Acme'}};

const copy = deepUpdate(original, ['work', 'employer'], 'Spectre');
assert.deepEqual(copy, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(original, {name: 'Jane', work: {employer: 'Acme'}});

How does non-destructive updating help with shared mutable state?  

With non-destructive updating, sharing data becomes unproblematic, because we never mutate the shared data. (Obviously, this only works if all parties do this.)

Intriguingly, copying data becomes trivially simple:

const original = {city: 'Berlin', country: 'Germany'};
const copy = original;

The actual copying of original happens only if and when it is necessary and we are making non-destructive changes.

Preventing mutations by making data immutable  

We can prevent mutations of shared data by making that data immutable. Next, we’ll examine how JavaScript supports immutability. Afterwards, we’ll discuss how immutable data helps with shared mutable state.

Background: immutability in JavaScript  

JavaScript has three levels of protecting objects:

  • Preventing extensions makes it impossible to add new properties to an object. You can still delete and change properties, though.
    • Method: Object.preventExtensions(obj)
  • Sealing prevents extensions and makes all properties unconfigurable (roughly: you can’t change how a property works anymore).
    • Method: Object.seal(obj)
  • Freezing seals an object after making all of its properties non-writable. That is, the object is not extensible, all properties are read-only and there is no way to change that.
    • Method: Object.freeze(obj)

For more information, see “Speaking JavaScript”.

Given that we want our objects to be completely immutable, we only use Object.freeze() in this blog post.

Freezing is shallow  

Object.freeze(obj) only freezes obj and its properties. It does not freeze the values of those properties – for example:

const teacher = {
  name: 'Edna Krabappel',
  students: ['Bart'],
};
Object.freeze(teacher);

assert.throws(
  () => teacher.name = 'Elizabeth Hoover',
  /^TypeError: Cannot assign to read only property 'name'/);

teacher.students.push('Lisa');
assert.deepEqual(
  teacher, {
    name: 'Edna Krabappel',
    students: ['Bart', 'Lisa'],
  });

Implementing deep freezing  

If we want deep freezing, we need to implement it ourselves:

function deepFreeze(value) {
  if (Array.isArray(value)) {
    for (const element of value) {
      deepFreeze(element);
    }
    Object.freeze(value);
  } else if (typeof value === 'object' && value !== null) {
    for (const v of Object.values(value)) {
      deepFreeze(v);
    }
    Object.freeze(value);
  } else {
    // Nothing to do: primitive values are already immutable
  } 
  return value;
}

Revisiting the example from the previous section, we can check if deepFreeze() really freezes deeply:

const teacher = {
  name: 'Edna Krabappel',
  students: ['Bart'],
};
deepFreeze(teacher);

assert.throws(
  () => teacher.name = 'Elizabeth Hoover',
  /^TypeError: Cannot assign to read only property 'name'/);

assert.throws(
  () => teacher.students.push('Lisa'),
  /^TypeError: Cannot add property 1, object is not extensible$/);

Immutable wrappers (advanced)  

An immutable wrapper wraps a mutable collection and provides the same API, but without destructive operations. We now have two interfaces for the same collection: One is mutable, the other one is immutable. This is useful when we have mutable internal data that we want to expose safely.

The next two sections showcase wrappers for Maps and Arrays. They both have the following limitations:

  • They are sketches. More work is needed to make them suitable for practical use: Better checks, support for more methods, etc.
  • They work shallowly.

An immutable wrapper for Maps  

Class ImmutableMapWrapper produces wrappers for Maps:

class ImmutableMapWrapper {
  constructor(map) {
    this._self = map;
  }
}

// Only forward non-destructive methods to the wrapped Map:
for (const methodName of ['get', 'has', 'keys', 'size']) {
  ImmutableMapWrapper.prototype[methodName] = function (...args) {
    return this._self[methodName](...args);
  }
}

This is the class in action:

const map = new Map([[false, 'no'], [true, 'yes']]);
const wrapped = new ImmutableMapWrapper(map);

// Non-destructive operations work as usual:
assert.equal(
  wrapped.get(true), 'yes');
assert.equal(
  wrapped.has(false), true);
assert.deepEqual(
  [...wrapped.keys()], [false, true]);

// Destructive operations are not available:
assert.throws(
  () => wrapped.set(false, 'never!'),
  /^TypeError: wrapped.set is not a function$/);
assert.throws(
  () => wrapped.clear(),
  /^TypeError: wrapped.clear is not a function$/);

An immutable wrapper for Arrays  

For an Array arr, normal wrapping is not enough because we need to intercept not just method calls, but also property accesses such as arr[1] = true. JavaScript proxies enable us to do this:

const RE_INDEX_PROP_KEY = /^[0-9]+$/;
const ALLOWED_PROPERTIES = new Set([
  'length', 'constructor', 'slice', 'concat']);

function wrapArrayImmutably(arr) {
  const handler = {
    get(target, propKey, receiver) {
      // We assume that propKey is a string (not a symbol)
      if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
        || ALLOWED_PROPERTIES.has(propKey)) {
          return Reflect.get(target, propKey, receiver);
      }
      throw new TypeError(`Property "${propKey}" can’t be accessed`);
    },
    set(target, propKey, value, receiver) {
      throw new TypeError('Setting is not allowed');
    },
    deleteProperty(target, propKey) {
      throw new TypeError('Deleting is not allowed');
    },
  };
  return new Proxy(arr, handler);
}

Let’s wrap an Array:

const arr = ['a', 'b', 'c'];
const wrapped = wrapArrayImmutably(arr);

// Non-destructive operations are allowed:
assert.deepEqual(
  wrapped.slice(1), ['b', 'c']);
assert.equal(
  wrapped[1], 'b');

// Destructive operations are not allowed:
assert.throws(
  () => wrapped[1] = 'x',
  /^TypeError: Setting is not allowed$/);
assert.throws(
  () => wrapped.shift(),
  /^TypeError: Property "shift" can’t be accessed$/);

How does immutability help with shared mutable state?  

If data is immutable, it can be shared without any risks. In particular, there is no need to copy defensively.

Non-destructive updating complements immutable data and makes it mostly as versatile as mutable data, but without the associated risks.

Libraries for avoiding shared mutable state  

There are several libraries available for JavaScript that support immutable data with non-destructive updating. Two popular ones are:

  • Immutable.js provides immutable (versions of) data structures such as List, Map, Set, and Stack.
  • Immer also supports immutability and non-destructive updating but for plain objects and Arrays.

These libraries are described in more detail in the next two sections.

Immutable.js  

In its repository, the library Immutable.js is described as:

Immutable persistent data collections for JavaScript which increase efficiency and simplicity.

Immutable.js provides immutable data structures such as:

  • List
  • Map (which is different from JavaScript’s built-in Map)
  • Set (which is different from JavaScript’s built-in Set)
  • Stack
  • Etc.

In the following example, we use an immutable Map:

import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
  [false, 'no'],
  [true, 'yes'],
]);

const map1 = map0.set(true, 'maybe'); // (A)
assert.ok(map1 !== map0); // (B)
assert.equal(map1.equals(map0), false);

const map2 = map1.set(true, 'yes'); // (C)
assert.ok(map2 !== map1);
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (D)

Explanations:

  • In line A we create a new, different version map1 of map0, where true is mapped to 'maybe'.
  • In line B, we check that the change was non-destructive.
  • In line C, we update map1 and undo the change made in line A.
  • In line D, we use Immutable’s built-in .equals() method to check that we really undid the change.

Immer  

In its repository, the library Immer is described as:

Create the next immutable state by mutating the current one.

Immer helps with non-destructively updating (potentially nested) plain objects and Arrays. That is, there are no special data structures involved.

This is what using Immer looks like:

import {produce} from 'immer/dist/immer.module.js';

const people = [
  {name: 'Jane', work: {employer: 'Acme'}},
];

const modifiedPeople = produce(people, (draft) => {
  draft[0].work.employer = 'Cyberdyne';
  draft.push({name: 'John', work: {employer: 'Spectre'}});
});

assert.deepEqual(modifiedPeople, [
  {name: 'Jane', work: {employer: 'Cyberdyne'}},
  {name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
  {name: 'Jane', work: {employer: 'Acme'}},
]);

The original data is stored in people. produce() provides us with a variable draft. We pretend that this variable is people and use operations with which we would normally make destructive changes. Immer intercepts these operations. Instead of mutating draft, it non-destructively changes people. The result is referenced by modifiedPeople. As a bonus, it is deeply immutable.

Acknowledgements  

  • Ron Korvig reminded me to use static factory methods and not overloaded constructors for deep-copying in JavaScript.

Further reading