1

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

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
Radu C
  • 1,340
  • 13
  • 31
  • If you have getters and setters for all your private fields you don't have any encapsulation safety anyway. – Bergi Oct 30 '22 at 15:59
  • a) `Object.create` does not create private slots, you need to call the constructor for that b) your `Example` class has no setter for `.priv` c) `Object.assign` does not copy inherited, non-enumerable properties – Bergi Oct 30 '22 at 16:02
  • I left out the setter to keep the code compact. It doesn't improve things when added. Your statement about encapsulation is true for simple getters and setters, but there are use cases where one may want to do a bit more stuff in there, for sure. Depending on IDE, it may also make a difference in code navigation. – Radu C Oct 31 '22 at 14:12
  • 1
    Private fields are private. They wouldn't be private if you could copy them with a function. – trincot Oct 31 '22 at 21:31
  • So far what I get is this: suddenly JS decided to implement proper OOP, and this is the one thing that I can't (and shouldn't) clone using JS facilities. I have to do things the traditional way, with a copy constructor (which is just a class method with no special meaning to JS) - edit: clone function, actually. Or... I'll just use the underscore convention, and hope it screams "bad code" if anybody tries to access those. All the while a `class` is still Object underneath, and nothing special except for private fields... Unless a transpiler doesn't implement proper semantics. Right? – Radu C Nov 01 '22 at 00:20
  • Just for reference, PHP doesn't moan about cloning private fields. Java can do it too, by jumping through a few hoops (but not more complicated than the Object.assign/Object.create dance in my question) with java.lang.Cloneable. Fields being private isn't an excuse. It's just JS that decided to be weird. I am not familiar with any other languages that present easy cloning mechanisms. – Radu C Nov 01 '22 at 01:12
  • @trincot It turns out private fields aren't that private after all... – Radu C Nov 09 '22 at 17:36

1 Answers1

0

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:

  • Objects of the same class can access each other's private fields
  • The only way to get an object to define its private fields is by calling its constructor.

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.

Radu C
  • 1,340
  • 13
  • 31