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.