javascriptcloneprivatees6-classclass-fields

Clone private fields of class to implement immutable pattern


I'm trying to use JS classes with private fields for a React app (because I still find it weird to use naked Object instances everywhere). React uses the concept of immutable state, so I have to clone my objects in order to change them. I'm using private fields - with getters and setters to access them. The problem I have is that private fields don't get cloned in Firefox, Chrome, or Node. Annoyingly, I had a false positive with my React project's Jest setup, where this code works as expected in unit tests.

Is there a way to get around this? Otherwise, it looks like I have to give up some of my (perceived) encapsulation safety and use underscore-prefixed "private" fields instead.

This is my cloning function:

const objclone = obj => {
  const cloned = Object.assign(
    Object.create(
      Object.getPrototypeOf(obj),
      Object.getOwnPropertyDescriptors(obj),
    ),
    obj,
  );

  return cloned;
};

This clones the getters and setters as well as the object properties and appears to work well until I use private fields.

Example:

class C {
  #priv;

  constructor() {
    this.#priv = 'priv-value';
  }

  get priv() { return this.#priv; }
}

const obj = new C();
console.log("obj.priv", obj.priv);

const cloned = objclone(obj);
console.log("cloned.priv", cloned.priv);

Error messages that are shown when trying to access cloned.priv:

Firefox:

Uncaught TypeError: can't access private field or method: object is not the right class

Chrome and Node:

Uncaught TypeError: Cannot read private member #priv from an object whose class did not declare it


Solution

  • I solved it. It's not as simple as I'd like - and I don't know if it can be made any simpler, but it looks pretty good to me.

    Keys in solving the problem:

    I created a Cloner class that can clone normal JS objects, but also object which implement one of two interfaces: cloneMe or copyOther. The cloneMe interface allows an object to create the clone, populate it and return it, while the copyOther interface lets the Cloner call new, which results in slightly less cloning code.

    An object has to implement one of these interfaces, and it is responsible for manually copying the private fields over. With a bit of luck, the mental overhead is minimal.

    I used Symbol to prevent identifier collisions. I hope I did it right, as I never used this before.

    class Cloner {
      static cloneMe = Symbol('clone');
      static copyOther = Symbol('copy');
    
      static clone(obj, init = []) {
        if (!(obj instanceof Object)) {
          // reject non-Object input
          throw new Error(`Cannot clone non-Object of type ${typeof(obj)}`)
        } else if (obj[this.cloneMe]) {
          // self-cloning object
          return obj[this.cloneMe](...init);
        } else if (obj[this.copyOther]) {
          // copier object
          const cloned = Object.assign(new obj.constructor(...init), obj);
          // ask the cloned object to copy the source
          cloned[this.copyOther](obj);
          return cloned;
        } else {
          // classic cloning
          return Object.assign(Object.create(
              Object.getPrototypeOf(obj),
              Object.getOwnPropertyDescriptors(obj),
            ),
            obj,
          );
        }
      }
    }
    

    Example cloneMe implementation:

    class MyClonerClass {
      #priv;
    
      constructor(init) {
        this.#priv = init;
      }
    
      [Cloner.cloneMe](...init) {
        const cloned = new this.constructor(...init);
        cloned.#priv = this.#priv;
        return cloned;
      }
    
      get a() {
        return this.#priv;
      }
    
      set a(value) {
        this.#priv = value;
      }
    }
    

    Example copyOther implementation:

    class MyCopierClass {
      #priv;
    
      constructor(init) {
        this.#priv = init;
      }
    
      [Cloner.copyOther](src) {
        this.#priv = src.#priv;
      }
    
      get a() {
        return this.#priv;
      }
    
      set a(value) {
        this.#priv = value;
      }
    }
    

    Usage:

    const copySrc = new MyCopierClass('copySrc.#a');
    const copyDst = Cloner.clone(copySrc);
    copyDst.a = 'copyDst.#a';
    console.log(copySrc.a);
    console.log(copyDst.a);
    
    const cloneSrc = new MyClonerClass('cloneSrc.#a');
    const cloneDst = Cloner.clone(cloneSrc);
    cloneDst.a = 'cloneDst.#a';
    console.log(cloneSrc.a);
    console.log(cloneDst.a);
    

    Not shown here is the init parameter of Cloner.clone. That can be used if the constructor expects certain parameters to exist, and a naked constructor wouldn't work.

    The cloneMe interface can take an init via the Cloner, or could supply its own based on internal state, keeping things nicely encapsulated and nearby.

    Extra credits

    While figuring this out, I thought up a way to simplify the cloning code quite a bit, by keeping the private fields in a dictionary. This crushes the TC39 hopes and dreams of a fixed compile-time list of private fields that cannot be added to or removed from, but it makes things a bit more Javascript-y. Have a look at the copyOther implementation - that's pretty much all of it, ever.

    class WeirdPrivPattern {
      #priv = {}
    
      constructor(a, b) {
        this.#priv.a = a;
        this.#priv.b = b;
      }
    
      get a() {return this.#priv.a;}
      set a(value) {this.#priv.a = value;}
    
      get b() {return this.#priv.b;}
      set b(value) {this.#priv.b = value;}
    
      [Cloner.copyOther](src) {
        this.#priv = {...src.#priv}
      }
    }
    

    A note on deep cloning: it is outside of the scope of this answer. I am not worried about deep cloning. I actually rely on child objects keeping their identity if not mutated.