Problems in sharing mutable state and how to avoid them

Keywords: Javascript JSON Attribute REST

By Dr. Axel Rauschmayer

Crazy technology house

Original text: https://2ality.com/2019/10/sh...

No reprint without permission

This paper answers the following questions:

  • Is shared mutable state?
  • Why is there a problem?
  • How to avoid its problems?

The section marked "(Advanced)" will go deeper, so if you want to read this article faster, you can skip it.

What is shared mutable state and why is there a problem?

The shared mutable state is explained as follows:

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

There may be a risk that one party's modification will cause the other party to fail to work properly. Here 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:'

There are two separate parts: the function logElements() and the function main(). The latter wants to print the contents of the array before and after sorting. But it uses logelements (), which causes the array to be emptied. So main () will output an empty array on line A.

In the rest of this article, we will introduce three ways to avoid sharing mutable states:

  • Avoid sharing by copying data
  • Avoid data changes through lossless updates
  • Prevent data changes by making data immutable

For each method, we'll go back to the example we just saw and fix it.

Avoid sharing by copying data

Before we start looking at how to avoid sharing, we need to see how to copy data in JavaScript.

Shallow copy and deep copy

For data, there are two replicable "depths":

  • Shallow copies copy only the top-level entries of objects and arrays. The original value and the input value in the copy are still the same.
  • The deep copy also copies the entry for the entry value. That is, it traverses the tree completely and copies all nodes.

Unfortunately, JavaScript has only built-in support for shallow copies. If you need a deep copy, you need to implement it yourself.

Shallow copy in JavaScript

Let's take a look at several ways to make light copies.

Copy common objects and arrays through propagation

We can Expand to object literal and Expand to array literal To copy:

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

But there are several limitations to communication:

  • Do not copy prototype:
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 are not copied.
  • Copies only its own (non inherited) properties. Whereas Prototype chain This is usually the best way. But you still need to be aware of that. In the following example, there is no inherited property. inheritedProp of original in copy, because we only copy our own property without preserving 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 attribute. length of an array instance cannot be enumerated or 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);
  • With property attributes Irrelevant, its copy is always a writable and configurable data attribute, 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,
  },
});

This means that neither the getter nor the setter will be copied as it is: the value property (for the data property), the get property (for the getter) and the set property (for the setter) are mutually exclusive.

const original = {
  get myGetter() { return 123 },
  set mySetter(x) {},
};
assert.deepEqual({...original}, {
  myGetter: 123, // not a getter anymore!
  mySetter: undefined,
});
  • Copy is shallow: the copy has a new version of each key entry in the original version, but the original value itself is not copied. 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 restrictions can be eliminated, while others cannot:

  • We can provide the replica with the same prototype as the original prototype during the copy process:
class MyClass {}

const original = new MyClass();

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

In addition, we can set the prototype through Object.setPrototypeOf() after the replica is created.

  • There is no simple way to copy special objects in general.
  • As mentioned earlier, it's a function, not a limitation, to copy only your own attributes.
  • We can use Object.getOwnPropertyDescriptors() and Object.defineProperties() to copy objects.( Operation method to be explained later):

    • They consider all properties (not just value), so they correctly copy getters, setters, read-only properties, etc.
    • Use Object.getOwnPropertyDescriptors() to retrieve enumerable and non enumerable properties.
  • We'll cover deep copy later in this article.

Shallow copy via Object.assign() (Advanced)

Object.assign() works as if it were propagated to an object. That is to say, the following two replication methods are roughly the same:

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

The advantage of using methods instead of syntax is that libraries can populate them on the old JavaScript engine.

However, Object.assign() is not exactly like propagation. It differs in a relatively subtle way: it creates attributes in different ways.

  • Object.assign() uses assignment to create the properties of the replica.
  • Propagates new properties in the definition copy.

Among other things, assignment calls its own and inherited setters, while definition does not( More about assignment and definition ) This difference is rarely noticed. The following code is an example, but it is designed artificially:

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 copy via Object.getOwnPropertyDescriptors() and Object.defineProperties() (Advanced)

JavaScript allows us to use property descriptor Create properties that specify property properties. For example, we've seen it through Object.defineProperties(). If you use this method with Object.getOwnPropertyDescriptors(), you can replicate more faithfully:

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

This eliminates two limitations of copying objects through propagation.

First of all, you can correctly copy all the attribute s of your property. We can now copy our getter s and setter s:

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

Secondly, because of the use of Object.getOwnPropertyDescriptors(), non enumeration properties are also copied:

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 copy of JavaScript

Now it's time to address deep copy. First we'll do the deep copy manually, and then we'll look at the general method.

Propagate manual deep copy through nesting

If you nest propagation, you get a deep copy:

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: general deep copy through JSON

Although this is a hack, it provides a quick solution at a critical moment: in order to make a deep copy of the 'original object', we first convert it to a JSON string, and then parse it:

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 main disadvantage of this method is that we can only copy properties with key and value supported by JSON.

Some unsupported keys and values will be ignored:

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

Other exceptions:

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

Achieve universal deep copy

You can use the following functions for general deep copy:

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

This function deals with three situations:

  • If original is an Array, we create a new Array and copy the elements of original into it.
  • If original is an object, we will use a similar method.
  • If original is the original value, no action is required.

Let's try 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() solves only one extension problem: shallow copy. All other contents: do not copy the prototype, only copy some special objects, ignore the non enumeration properties, and ignore most properties.

It's usually impossible to fully implement replication: not all data is a tree, sometimes you don't need all the attributes, and so on.

More concise version of deepCopy()

If we use. map() and Object.fromEntries(), we can make the previous implementation of deepCopy() more concise:

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 copy in classes (Advanced)

Generally, two technologies are used to realize deep copy of class instances:

  • . clone() method
  • copy constructor
. clone() method

This technology introduces a method. clone() for each class, in fact, the example will be copied in depth. 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 shows an important aspect of this technique: composite instance property values must also be recursively cloned.

Static factory method

Copy constructor is to use another instance of the current class to set the constructor of the current instance. Copy constructors are popular in static languages such as C + + and Java, where you can provide multiple versions of constructors through static overloading (which means it happens at compile time).

In JavaScript, you can do the following (but 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 to use:

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

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

In the following example, three classes Point, Color, and ColorPoint have static factory methods. from(), respectively:

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 use recursive replication again.

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 copy help share mutable States?

As long as we only read from the shared state, there is no problem. Before we can modify it, we need to "unshare" it by copying (the necessary depth).

Defensive replication is a technology that replicates all the time when problems may arise. Its purpose is to ensure the security of current entities (functions, classes, etc.):

  • Input: copies (potentially) the shared data passed to us so that we can use it without interference from external entities.
  • Output: copying internal data before exposing it to external parties means that our internal activities will not be disrupted.

Please note that these measures can protect us from other parties as well as from us.

The next section describes two types of defensive replication.

Copy shared input

Remember, in the example at the beginning of this article, we had trouble because logElements() modified its parameter arr:

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

Let's add defensive replication to this function:

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

Now, if logElements() is called inside main(), it will no longer cause problems:

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'

Copy exposed internal data

Let's start with the StringBuilder class, which does not copy the 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() is not used, everything can work normally:

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

However, if you change the result (row A) of. getParts(), StringBuilder stops working:

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 it defensively (line A) before it is exposed internally.

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() no longer interferes with sb's operation:

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

Avoid data changes through lossless updates

We will first explore the difference between updating data in a destructive and non-destructive way. You will then learn how non-destructive updates can avoid data changes.

Background: destructive and non-destructive renewal

There are two different ways to update data:

  • The destructive update of data changes the data and makes the data have the required form.
  • A non-destructive update of the data creates a copy of the data in the required format.

The latter approach is similar to copying and then changing it destructively, but at the same time.

Example: updating objects in a destructive and non-destructive way

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

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

The following functions change properties in a non-destructive way:

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

Its usage is 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'});

Propagation makes setObjectNonDestructively() simpler:

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

Note: both versions of setObject NonDestructively() have been slightly updated.

Example: updating arrays in a destructive and non-destructive way

Here's how to destructively set array elements:

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

Updating arrays non destructively is much more complex.

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 extension make setArrayNonDestructively() simpler:

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

Note: both versions of setArrayNonDestructively() have been slightly updated.

Manual depth update

So far, we have only updated the data in a shallow way. Let's solve the deep update. The following code shows how to do this manually. We are changing name and employee.

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

Achieve universal depth update

The following functions implement a general depth update.

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 think of value as the root of the tree to update, deepoupdate () only changes A single branch (rows A and C) in depth. All other branches are shallowly copied (rows B and D).

Here's what it looks like to use deepoupdate():

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 can non-destructive updates help share mutable States?

With non-destructive updates, shared data will become no problem, because we will never change shared data. (obviously, this only works if all parties do.)

Interestingly, it's very easy to replicate data:

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

The actual replication of the original is only done when necessary and when we make lossless changes.

Prevent data from changing by keeping the data unchanged

We can prevent the shared data from changing by keeping the shared data unchanged. Next, we'll look at how JavaScript supports immutability. Then, discuss how immutable data can help share mutable states.

Background: invariance in JavaScript

JavaScript has three levels of protected objects:

  • Preventing extensions makes it impossible to add new properties to an object. However, you can still delete and change properties.

    • Method: Object.preventExtensions(obj)
  • Sealing prevents expansion and makes all properties unconfigurable (approximately: you can no longer change how properties work).

    • Method: Object.seal(obj)
  • Freezing seals all properties of an object when they are not writable. In other words, the object is not extensible. All properties are read-only and cannot be changed.

    • Method: object. Free (obj)

For more information, see "Speaking JavaScript".

Since we want objects to be completely immutable, only Object.freeze() is used in this article.

Shallow freezing

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

Achieve deep freeze

If you want to freeze deeply, you need to implement it yourself:

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

Looking back at the examples in the previous section, we can check whether deepFreeze() is actually frozen:

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 wrapper (Advanced)

Wrapping variable set merges with immutable wrappers provides the same API, but no disruptive operation. Now for the same set, we have two interfaces: one is mutable, the other is immutable. This is useful when we have publicly changeable data that we want to be secure.

Next, we show the wrappers for Maps and Arrays. They all have the following limitations:

  • They are relatively simple. In order to make them suitable for practical use, more work needs to be done: better inspection, support for more methods, etc.
  • They are shallow copies.

Invariant wrapper of map

The immutable mapwrapper class generates a wrapper for a map:

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$/);

Immutable wrapper for arrays

For array arr, regular wrapping is not enough, because we need to intercept not only method calls, but also property accesses such as arr [1] = true. JavaScript proxies Enables 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 invariance help to share mutable States?

If the data is immutable, the data can be shared without any risk. In particular, there is no need for defensive replication.

Non destructive update is a supplement to constant data, making it as common as variable data, but without related risks.

Libraries to avoid sharing mutable States

There are several libraries available for JavaScript that support lossless updates to immutable data. Two of them are popular:

  • Immutable.js Provides immutable (version) data structures, such as List, Map, Set, and Stack.
  • Immer Non volatile and non-destructive updates are also supported, but only for normal objects and arrays.

Immutable.js

In its repository, Immutable.js Described as:

Immutable persistent data sets for JavaScript that improve efficiency and simplicity.

Immutable.js provides immutable data structures, such as:

  • List
  • Map (different from JavaScript's built-in map)
  • Set (different from JavaScript's built-in set)
  • Stack
  • etc.

In the following example, we use immutable maps:

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)

Explain:

  • In line A, we create A different version of map0 map1, where true is mapped to 'maybe'.
  • In line B, we check that the changes are non-destructive.
  • In line C, we update map1 and undo the changes made in line A.
  • In line D, we use the Immutable's built-in. equals() method to check if we actually undo the changes.

Immer

In its repository, Immer Library Described as:

Create the next immutable state by changing the current state.

Immer helps you update (possibly nested) common objects and arrays non destructively. In other words, no special data structure is involved.

This is what it looks like to use Immer:

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() gives us a variable draft. Let's assume that this variable is people and use operations that usually make disruptive changes. Immer intercepted these operations. Instead of mutated draft, it changes people losslessly. The result is referenced by modifiedPeople. It is immutable.

Thank

  • Ron Korvig Remind me to use static factory methods for deep copy in JavaScript instead of overloading constructors.

Extended reading

This article starts with WeChat public: front-end pioneer.

Welcome to scan the two-dimensional code to pay attention to the public number, push you every day to send fresh front-end technical articles.

Welcome to other great articles in this column:

Posted by SensualSandwich on Mon, 28 Oct 2019 20:28:37 -0700