I've built a sample using the Typescript playground, but the behaviour isn't related to typescript specifically. It's a bit easier to show in code (link includes compiled JS, for those looking to ignore TS):
If you extend a class which uses an indexed array as a property
class BaseClass {
_meta: {[key: string]: string};
constructor() {
this._meta = {};
}
}
class MyClass extends BaseClass {
prop1?: string;
constructor(init: Partial<MyClass>){
super()
Object.assign(this, init);
}
}
Then from a base model use the reflection API to create other instances, you can get different behaviours depending how you build your subsequent models.
If you create a new object to assign to _meta
, it works the way you'd expect it to:
const baseInstance = new MyClass({
prop1: 'base',
_meta: {
hello: 'world'
}
})
const reflectInstance = Reflect.construct(MyClass, [baseInstance]);
reflectInstance._meta = { hello: 'reflection' }
alert("1 " + JSON.stringify(baseInstance)) // as expected
alert("2 " + JSON.stringify(reflectInstance)) // as expected
but if you assign to the container using array notation, the scope becomes contaminated, i.e. it shares scope with the source model:
const contaminatedInstance = new MyClass({
prop1: 'contaminated',
_meta: {
goodbye: 'world'
}
})
const bogusInstance = Reflect.construct(MyClass, [contaminatedInstance]);
bogusInstance._meta['goodbye'] = 'contaminated';
alert("3 " + JSON.stringify(contaminatedInstance)) // _meta has unexpectedly changed
alert("4 " + JSON.stringify(bogusInstance)) // as a result of bogusInstance
Does anyone know why this is? I can fuzzily justify things by saying the _meta property has a common address, and because it's extended there wasn't a new
invocation of the base model thus making it common; but this is a difficult case to remember when it comes up outside of unit testing; especially during a PR.
Any advice on how to avoid this while still using array notation would be great.
Thank you!
That has nothing to do with Reflect. You'll get the same behaviour with:
new MyClass(new MyClass({ shared: "false", _meta: { shared: "true" } })
It's just that Object.assign
shallow copies, so the _meta
property of both instances will contain a reference to the same object..
Some pseudo in-memory structure to make that more clear:
#1 { // MyClass
[[Construct]]: Code,
prototype: #2
}
#2 { // MyClass.prototype
constructor: #1,
// yet empty
}
#3 { shared: "true" } // the _meta object, created inside the object passed to the first instance constructor
#4 { // the object passed to the first instance constructor
shared: "false",
_meta: #3,
}
#5 { // the first MyClass instance
[[prototype]]: #2,
shared: "false", // shallow copied from #4
_meta: #3
}
#6 { // the second MyClass instance
[[prototype]]: 2,
shared: "false", // shallow copied from #5
_meta: #3, // same reference as #4._meta
}