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?
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)
}
}
}
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
[[Prototype]]
points to the constructor's prototype i.e. Object.getPrototype(emptyObject) === A.prototype
or emptyObject.__proto__ === A.prototype
A
with the empty object set as its context i.e. inside the function this
is the empty objectreturn 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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With