Search code examples
javascripttypescriptassociative-arrayreflect

When using Javascript's Reflect API to build an instance of a class that extends another, why are indexed arrays a shared scope?


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!


Solution

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