Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Object.freeze in constructor with subclasses

Tags:

javascript

If I want my classes to be immutable, I understand I can use Object.freeze(). Now if I want my objects to be immutable after construction, I would put Object.freeze(this) into my constructor as the last line. But now, if I want to subclass that, I can't add more parameters because I can't call this before calling super and after calling super it's immutable:

class A {
  constructor(x) {
    this.x = x
    Object.freeze(this)
  }
}

class B extends A {
  constructor(x, y) {
    this.y = y // nope. No "this" before "super"
    super(x)
    this.y = y // nope. "this" is already frozen
    Object.freeze(this)
  }
}

I was thinking about figuring out if the constructor was called via super in order to skip freezing and leaving freezing to the subclass but that would leave it to the subclass to freeze or not. How would I best approach this? With a factory maybe?

like image 207
Giszmo Avatar asked May 07 '16 03:05

Giszmo


2 Answers

First of all, it does seem odd that you want to mix classes, whose fundamental tenet is basically stateful mutability, with immutability. I think, a composable factory function with composition might be more idiomatic (there might be better ways to do it than the following):

function compose(funcs...) {
  return function(...args) {
    const obj = funcs.reduce((obj, func) = >{
      return func(obj, args);
    }, {});
  }
  return Object.freeze(obj);
}

function a(obj, { x }) {
  obj.x = x;
  return obj;
}

function b(obj, { y }) {
  obj.y = y;
  return obj;
}

const ab = compose(a, b);

ab({ x: 1, y: 2 });

Alternatively, if you want to stick with the class syntax, you could use new.target to check if the constructor call in A is a super call or not:

class A {
  constructor(x) {
    this.x = x;
    // Not a super call, thus freeze the object
    if (new.target === A) {
      Object.freeze(this);
    }
  }
}

class B extends A {
  constructor(x, y) {
    super(x)
    this.y = y
    if (new.target === B) {
      Object.freeze(this)
    }
  }
}
like image 76
nils Avatar answered Sep 21 '22 04:09

nils


You might add this functionality with a proxy's construct trap, basically instead of calling the constructor function of each class you call an intermediate function which should add the functionality you need and also create the instance

Let me explain how this could be done in ES5 first and then jump to ES6

function A() {
  this.name = 'a'
}
A.prototype.getName = function () {
  return this.name
}
var instance = new A()
instance.getName() // 'a'

the magic keyword new does the following

  1. create an empty object whose [[Prototype]] points to the constructor's prototype i.e. Object.getPrototype(emptyObject) === A.prototype or emptyObject.__proto__ === A.prototype
  2. calls the function A with the empty object set as its context i.e. inside the function this is the empty object
  3. returns the object for us (we don't have to write return this)

We could mimic this behavior with the following

function freezeInstance(T) {
  function proxy () {
    // 1.
    var instance = Object.create(T.prototype)
    // 2.
    T.apply(instance, arguments)
    // this check is added so that constructors up
    // in the constructor chain don't freeze the object
    if (this instanceof proxy) {
      Object.freeze(instance)
    }
    // 3.
    return instance
  }

  // mimic prototype
  proxy.prototype = T.prototype

  return proxy
}

If we call this function with a function and assign the inner function to the argument T we have effectively created the same behavior as new and also we can add our custom functionality inside the proxy i.e.

A = freezeInstance(A)
var instance = new A()
instance.getName() // 'a'
a.name = 'b'
instance.getName() // 'a'

If you have a subclass the freeze behavior won't affect superclasses since we only do the special functionality for initial call

function freezeInstance(T) {
  function proxy() {
    var instance = Object.create(T.prototype)
    T.apply(instance, arguments)
    if (this instanceof proxy) {
      Object.freeze(instance)
    }
    return instance
  }

  // mimic prototype
  proxy.prototype = T.prototype

  return proxy
}

function A() {
  this.name = 'a'
}
A.prototype.getName = function() {
  return this.name
}
A = freezeInstance(A)

function B() {
  A.call(this)
  this.name = 'b'
}
B.prototype = Object.create(A.prototype)
B = freezeInstance(B)

var instance = new B()
document.write(instance.getName()) // 'b'
instance.name = 'a'
document.write(instance.getName()) // 'b'

In ES6 you can do the same with

function freezeInstance(T) {
  return new Proxy(T, {
    construct: function (target, argumentLists, newTarget) {
      var instance = new target(...argumentLists)
      Object.freeze(instance)
      return instance
    }
  })
}

class A {
  constructor() {
    this.name = 'a'
  }

  getName() {
    return this.name
  }
}
A = freezeInstance(A)

class B extends A {
  constructor() {
    super()
    this.name = 'b'
  }
}
B = freezeInstance(B)

var a = new A()
console.log(a.getName()) // a
a.name = 'b'
console.log(a.getName()) // a

var b = new B()
console.log(b.getName()) // b
b.name = 'a'
console.log(b.getName()) // b

The nice thing about proxies is that you don't have to change your implementation, you just wrap your implementation inside something else

ES6 demo

EDIT: as @nils noted Proxies cannot be transpiled/polyfilled due to limitations of ES5, refer to the compatibility table for further info on the platforms that support this tech

like image 35
Mauricio Poppe Avatar answered Sep 20 '22 04:09

Mauricio Poppe