hguser
hguser

Reputation: 36018

Change property value initialized in constructor outside the class

//clazz.js:

class Clazz {
  constructor() {
    this.name = "name";
    this.num= 8;
  }
}
export default Clazz;

//main.js

import Clazz from "./clazz"
let oc = Clazz.prototype.constructor;
Clazz.prototype.constructor = function(){
    oc.apply(this,arguments)
    this.num= 9
}

let c = new Clazz()
console.info(c)

While I expect the num of the c will be 9, but it is still 8.

What's going one? And is it possible to fix that?

Upvotes: 0

Views: 2093

Answers (2)

aweebit
aweebit

Reputation: 646

The best you can do to achieve the desired result is to use Proxy with a handler implementing the construct() method which is a trap for the new operator. This way you get objects that are indistinguishable from objects you would get by calling the constructor before using the proxy. In particular, the constructor property of objects created using the proxy is equal to the original class, and calling Object.getPrototypeOf() on them gives us back its prototype.

Interestingly, even objects created using the original constructor will be considered instances of the proxy. I guess it has to do with the fact that accessing the Symbol.hasInstance property of the proxy gives us back the corresponding property of the original class (this is the default behavior we get because we do not implement the get() method of the proxy handler). But this goes far beyond the scope of this question…

Now let us take a look at the code:

class Clazz {
  constructor() {
    this.name = "name";
    this.num = 8;
  }
}

let oc = Clazz.prototype.constructor;
Clazz = new Proxy(Clazz, {
  construct(target, args, newTarget) {
    const result = Reflect.construct(target, args, newTarget);
    result.num = 9;
    return result;
  }
});

let c = new Clazz();
console.log(c);

console.log(c instanceof Clazz);
console.log(c instanceof oc);
console.log(c.constructor === oc);
console.log(Object.getPrototypeOf(c) === oc.prototype);

console.log(new oc() instanceof Clazz);
console.log(Clazz[Symbol.hasInstance] === oc[Symbol.hasInstance]);

console.log(Clazz !== oc);


Possible Improvement (if we want to be annoying)

This solution works perfectly for your class and there is no need for improvement, so you can stop reading the answer here because what follows are a lot of technical details which are irrelevant for your basic example.

But if you want a general solution, there is one little problem left to consider. As you have probably noticed, I use Reflect.construct() to simulate creating new instances of the class as if they were created using the original constructor. The interesting part of it is the third parameter which gets assigned the value new.target will have in the constructor we are calling. new.target is a feature of ES6 we rarely have to care about, but we need it here, so I recommend you read about it by following the link if it is the first time you see it.

As you can see in the code, I just pass the newTarget parameter our trap receives down to the original constructor. You will probably ask why we even have to care about it given that we know it is always going to be equal to Clazz whenever we call new Clazz(), and omitting the new operator results in an error anyway, so it cannot be undefined. (Even if there were no error, it would not be caught by the construct() trap, but by the apply() one if we specified it.)

The reason we care is the power Reflect.construct() gives us. This method is not limited to proxy handlers and can be used to simulate construction of class instances anywhere in our code, which means that we can force new.target to be equal to an arbitrary function (that qualifies as a constructor, i.e. has a [[Construct]] internal method) since we can pass such a function to Reflect.construct() in the third parameter I have mentioned above.

I hope this small example makes it clear:

class MyClass {
  constructor() {
    console.log("new.target  ===", new.target);
  }
}

const args = [];
const newTarget = function Arbitrary() {};

const myObject = Reflect.construct(MyClass, args, newTarget);
console.log(myObject instanceof newTarget);

So, in order to make constructing new instances with the proxy as close as possible to constructing them with the original constructor, we have to pass the newTarget parameter our proxy receives down to the original constructor, and the only way to do it I know of is, again, by calling Reflect.construct().

Okay, but the code above already does that. So why do we have to talk about it?

Well, I have already mentioned that newTarget is going to be equal to Clazz whenever we call new Clazz(), and that presents a significant flaw if we want to stay as close as possible to the original constructor, since Clazz is not equal to the original class after we assign the proxy to the Clazz variable (i.e. Clazz !== oc), which means that calling new Clazz() before and after assigning the proxy could produce different results. Consider the following quirky class as an example:

class Clazz {
  constructor() {
    if (new.target !== Clazz) {
      throw new TypeError("Forbidden!");
    }

    this.name = "name";
    this.num = 8;
  }
}

class Clazz {
  constructor() {
    if (new.target !== Clazz) {
      throw new TypeError("Forbidden!");
    }

    this.name = "name";
    this.num = 8;
  }
}

Clazz = new Proxy(Clazz, {
  construct(target, args, newTarget) {
    const result = Reflect.construct(target, args, newTarget);
    result.num = 9;
    return result;
  }
});

let c = new Clazz();

The constructor throws an error whenever new.target is not equal to the original class, so it will not be possible to construct class instances using the new operator once we create the proxy, because new.target will be equal to the proxy, and not the original class.

The fix is pretty straightforward: we just pass the original class as new.target to the constructor if the newTarget parameter our proxy handler receives is equal to our proxy, which is now known under the name Clazz. Like so:

const result = Reflect.construct(
  target, args, newTarget === Clazz ? target : newTarget
);

But we have to be careful here because Clazz could be reassigned later, which would break the handler function. Here is a code snippet to demonstrate the problem:

class Clazz {
  constructor() {
    if (new.target !== Clazz) {
      throw new TypeError("Forbidden!");
    }

    this.name = "name";
    this.num = 8;
  }
}

Clazz = new Proxy(Clazz, {
  construct(target, args, newTarget) {
    console.log("Clazz is now:", Clazz);
    const result = Reflect.construct(
      target, args, newTarget === Clazz ? target : newTarget
    );
    result.num = 9;
    return result;
  }
});

const ClazzRef = Clazz;
Clazz = "Sike";

let c = new ClazzRef();

We can solve this by using an Immediately Invoked Function Expression to form a closure, and this is going to be our final solution:

class Clazz {
  constructor() {
    if (new.target !== Clazz) {
      throw new TypeError("Forbidden!");
    }

    this.name = "name";
    this.num = 8;
  }
}

Clazz = (function() {
  const proxy = new Proxy(Clazz, {
    construct(target, args, newTarget) {
      const result = Reflect.construct(
        target, args, newTarget === proxy ? target : newTarget
      );
      result.num = 9;
      return result;
    }
  });
  
  return proxy;
})();

const ClazzRef = Clazz;
Clazz = "Sike";

let c = new ClazzRef();
console.log(c);


Wrapping the solution in a library function

It is possible to wrap this solution in a library function, and what follows is my attempt to do so as well as an interesting example of how it could be used.

function hookClass(target, hookObject, className) {
  const handler = {
    construct(target, args, newTarget) {
      console.log(`Called new ${newTarget.name}(${args.join(", ")})`);

      const result = Reflect.construct(
        target, args, newTarget === proxy ? target : newTarget
      );

      Reflect.apply(
        hookObject, // call this function
        result,     // -> this
        arguments   // <- argument list
      );

      return result;
    }
  };

  // We have to allow custom name property for logging purposes
  if (typeof className === "string") {
    const classNameTarget = {};
    Object.defineProperty(classNameTarget, "name", {
      value: className,
      writable: false,
      enumerable: false,
      configurable: true
    });

    Object.assign(handler, {
      get(target, key, receiver) {
        if (key === "name") {
          target = classNameTarget;
        } else if (receiver === proxy) {
          receiver = target;
        }

        return Reflect.get(target, key, receiver);
      },
      
      // We must also specify other handler methods
      // in order to cover all possible use cases,
      // but I am going to skip that part here...
    });
  }

  const proxy = new Proxy(target, handler);
  
  return proxy;
}

/* Example with your class */

class Clazz {
  constructor() {
    this.name = "name";
    this.num = 8;
  }
}

Clazz = hookClass(Clazz, function() {
  this.num = 9;
});

let c = new Clazz();
console.log(c);

/* A more sophisticated example */

class Counter {
  constructor(initialValue) {
    this.value = initialValue;
  }
  
  increase() {
    this.value++;
  }
}

// Boosting a counter class by X means that every step of
// the original class counts as X steps in the boosted one.

// What follows is definitely not the best way to create counters
// with an arbitrary step length, but it gives us a good example.

// boostCounterBy is a function factory / higher order function that returns
// a hook function that boosts a newly created counter class instance by X.
function boostCounterBy(boost) {
  return function(target, args, newTarget) {
    const [initialValue] = args;
    if (this.value === initialValue) {
      console.log("Has not been boosted yet");
    } else {
      console.log("Has already been boosted by", this.value / initialValue);
    }

    console.log("Boosting by", boost);
    this.value *= boost;

    const oldIncrease = this.increase.bind(this);

    // Note that we have to create a new method for each object instead of
    // working with the prototype. We cannot change the prototype because
    // it would affect the existing instances of the original counter class
    this.increase = function increase() {
      for (let i = 0; i < boost; i++) {
        oldIncrease();
      }
    };
  };
}

// We do not have to reassign Counter.
// That lets us create more than one
// proxy of the same class (see below)
const DoubleCounter = hookClass(Counter, boostCounterBy(2), "DoubleCounter");

// Hooking into a proxy!!!
// Not something you would want to do
// in real code because of the overhead 
const SextupleCounter = hookClass(DoubleCounter, boostCounterBy(3), "SextupleCounter");

// Reassigning the original Counter variable
// still works and does not break anything
Counter = hookClass(Counter, boostCounterBy(5));

[Counter, DoubleCounter, SextupleCounter].forEach(Counter => {
  console.log("\n");
  const counter = new Counter(1);
  console.log(counter.value);
  counter.increase();
  console.log(counter.value);
  counter.increase();
  console.log(counter.value);
});

console.log("\n");

DoubleCounter.property = {};
console.log(DoubleCounter.property === Counter.property); // true
// Is this behavior desired?..

// If not, here is a solution idea:
// create proxy on a deep copy of the class but call
// Reflect.construct() with original class as target


Bottom line

Classes in JavaScript are essentially just functions (constructor functions, to be specific), and since there is no way to change a function's code once it has been created, there is also no way to update the constructor of a class while keeping its identity. But the meta programming concepts introduced to JavaScript in ES6 such as Proxy and Reflect make the transition from a class to its new version as seamless as it can get.

I know this answer is kind of late, but I hope you found it useful nonetheless :)

Upvotes: 0

Bergi
Bergi

Reputation: 664297

Replacing the .constructor property of the prototype object doesn't help with anything. The constructor is Clazz itself, and you are calling it directly through new Clazz() - it doesn't create an object and invoke a "constructor method" on it.

Is it possible to fix that?

Not really, no. All you can do is to create a new function (a constructor even) that calls the old one (e.g. by subclassing), and then ensure that you only call the new one with new.

Upvotes: 2

Related Questions