Immutable objects with property descriptors in javascript?
Javascript is a flexible language so its quite easy to get started but once our project gets complex we start to feel the pain in 2 major areas.
- Type Checking Madness: Since a value can be anything i.e string, int, object etc, we have to perform a lot of checks on variables. Microsoft’s Typescript (that transpiles to JS) is a huge relief in this. we also have strict mode in JS that helps a bit.
- Mutable Data Structures: we started our object with something else but now our object is something else (God knows what happened in between), So to make sure our Data structures dont have any unintended modifications we use libraries like Immutable.js. We also have simple & native solutions like Object.seal() & Object.freeze() which we will cover in next article.
In this article and the next we will only worry about the second point regarding how to prevent Mutable data structures ( objects ) in JavaScript.
So we shall see object Immutability using native Object methods spanning in 2 articles
- Part 1: In this article I am using Immutability as an excuse to dwell deep into property descriptors in general and then into enumerable, writable & configurable specifically. we will manipulate them manually to make object immutable.
- Part 2 (here): In this one we shall see how native functions Object.seal(), & Object.freeze() manipulates the property descriptors under the hood to make object immutable.
One quick reminder before we start, we will use the term property, attribute and key a lot so better not to confuse between them, property generally corresponds to both key-value pair and often conceived as an Object in itself, attribute is somewhat similar as property whereas key just means the property key and its a String.
var myObj = {key1: "value1", key2: "value2"}// property = key + value , i.e {key1: "value1"} is one of the properties of myObj// keys of myObj are "key1" and "key2"
Now first of all what is this object immutability fuss about?
Immutability in object means we dont want our objects to change in any ways once we create them i.e make them read-only type. Here we have defined an object but it can be altered in many ways. we have considered 4 main ones.
var myObj = {
key1: 10,
key2: { innerKey: 100 }
};// 1. We can modify existing property values
myObj.key1 = 20;// 2. We can add new properties to myObj
myObj.key3 = "New property";// 3. We can delete existing properties
delete myObj.key2;// 4. We can even re-assign the whole myObj with new value
myObj = "Morpheus";
This might look casual but in complex codes where we wish to preserve some object state, its a big pain and when someone says make this object immutable they mean that- “once an object is created, its state cannot be modified i.e we cannot perform any of the above modifications to that object”.
Why do I need to know about enumerable, writable & configurable descriptors for object Immutability?
As we know objects are made up of properties and when we say we want an object to not change then we mean that all the properties in that object shouldnt change. We achieve that by modifying their corresponding property descriptors where enumerable, writable & configurable descriptors play a big part. So lets get to know these descriptors.
What are these Property descriptors?
In Javascript a descriptor is something that describes about the object property so each property has their own descriptor object that consist of any of these 6 descriptor attributes: value, enumerable, writable, configurable, get and set. We shall commonly refer to them as descriptors.
So each property has these hidden descriptors that can be seen using the Object.getOwnPropertyDescriptor() method, Lets check them out its really cool.
var myObj = {
key1: 10,
get key2(){ return 20;}
}// when we do normal logging of myObj properties
// we only see its valueconsole.log(myObj.key1); // 10
console.log(myObj.key2); // 20// But if we use Object.getOwnPropertyDescriptor(),then
// We see all their descriptorsconsole.log(Object.getOwnPropertyDescriptor(myObj, 'key1'));
/* { value: 10,
writable: true,
enumerable: true,
configurable: true }*/console.log(Object.getOwnPropertyDescriptor(myObj, 'key2'));
/* { get: [Function: get key2],
set: undefined,
enumerable: true,
configurable: true } */
As a JS programmer its always surprising to me how much internal data lies hidden beneath the hood for each object. we can clearly see that both properties key1 and key2 has these extra properties.
Its important to note that property descriptors are not normal object properties and we can work with them using these methods only.
- Object.getOwnPropertyDescriptor(): To read property descriptors, as shown in example above.
- Object.defineProperty(): To write property descriptors, it can create a property and set its descriptors or change the descriptors for any existing property.
- Object.defineProperties(): Same as above but can create/modify multiple properties with their corresponding descriptors.
- Object.create(): It can also be used to create object properties with custom descriptors, where it takes the object with property & descriptors as second argument.
We will see these methods in action throughout the 2 articles, so its ok if they dont make much sense now.
Little bit more about Property descriptors
We got to know something about descriptors and while we are at it lets dive deeper coz we should aslo know that a property can use only one of the 2 types of descriptors. yep there are like 2 modes of descriptors
1. Data descriptor (holds info about data i.e normal value based)
2. Accessory descriptor ( holds info about accessors i.e when we use getter/setters as object properties)
We can easily differentate between Data and Accessor type based on which descriptors are used
- value & writable are related only to Data descriptors
- get & set are related only to Accessor descriptors
- enumerable & configurable are common to both types
We can confirm this in previous example where “key1” has Data descriptor and “key2” has Accessor descriptor. I urge you to match the results in previous example with below table.
In previous example descriptor values for key1 and key2 was set by default, lets set the descriptor values ourselves in next examples using Object.defineProperty(), which lets us define new properties with their corresponding descriptors. If we miss any descriptor then these default values will be used instead.
Quick Note: when using Object.defineProperty() or Object.defineProperties() to create new properties then we get default values for writable, enumerable & configurable as false, which is different from other ways where we get true as default value. Compare the previous example with below ones to check that out.
Data Descriptor
Mandatory Properties: value,
Optional Properties: writable, enumerable, configurable
Whenever we use the descriptor “value” then it means that property is defined using a Data descriptor, and when we read the descriptors back it becomes evident by seeing output.
var myObj = {};Object.defineProperty(myObj, "key1", {value: 10});console.log(myObj.key1); // 10console.log(Object.getOwnPropertyDescriptor(myObj, 'key1'));
/*{ value: 10,
writable: false,
enumerable: false,
configurable: false }*/
Accessor Descriptor
Mandatory Properties: get or set or both
Optional Properties: enumerable, configurable
When we use getters and setters as object properties then those properties use Accessor descriptors to describe themselves, hence they use these 4 descriptor attributes get, set, enumerable, configurable only(Not value & writable).
var myObj = {};Object.defineProperty(myObj, "key2", {
get: function(){
return 20;
}
});console.log(myObj.key2); // 20console.log(Object.getOwnPropertyDescriptor(myObj, 'key2'));
{ get: [Function: get],
set: undefined,
enumerable: false,
configurable: false }
Now we have some solid idea about descriptors, but we still have not seen their uses yet. I hope looking from examples above value, get & set descriptors seem quite self-explanatory, so lets jump into the 3 main ones i.e. enumerable, writable & configurable.
enumerable (Harry Potter’s invisibility cloak)
It doesnt actually help in attaining immutability but it has a super cool purpose, its like an Invisibility cloak for properties when set to false.
By default enumerable is set to true for all properties and it works normally but if enumerable is set to false then that property is called as non-enumerable property and it helps to hide that property from any unintended modifications from user.
var myObj = {
key1: 10,
key2: 20,
};// key1 is enumerable by default
console.log(Object.getOwnPropertyDescriptor(myObj, "key1").enumerable); // true// key2 is enumerable by default
console.log(Object.getOwnPropertyDescriptor(myObj, "key2").writable); // true// Lets make key2 Non-enumerable and see what happens
Object.defineProperty(myObj, 'key2', {enumerable: false});console.log(Object.getOwnPropertyDescriptor(myObj, "key2").enumerable); // false
Non-enumerable properties have special behaviour as they make that property hidden for common operations, they
- Wont appear when logging the whole object
- Not accessible in for..in iterations
- Property name will not appear in resultant array of Object.keys()
- Dont appear in JSON.stringify()
console.log(myObj)
// {key1: 10}for (var key in myObj)
console.log(myObj[key]);
// 10console.log(Object.keys(myObj));
// ["key1"]console.log(JSON.stringify(myObj));
// {"key1": 10}
But know that key2 is still there and we can access it if we know the property name using .(dot operator) or [] operator, its a really cool feature to hide properties from unintentional modifications.
console.log(myObj.key2); // 20
console.log(myObj["key2"]); // 20
Even if we dont know the name of a non-enumerable property, we can still use Object.getOwnPropertyNames() method, that works similar to Object.keys() and returns an array of keys for an object, but without bothering about enumerable property. Hence it lists all the properties in an object.
console.log(Object.getOwnPropertyNames(myObj));
// ["key1", "key2"]
Conclusion: The general rule is that non-enumerable properties are used by system/API designers and enumerable properties are used by Users.
writable
Used in Data descriptors only and if set to false for a property then that property is called non-writable and it cannot be re-assigned, its like a ES6 const for object properties.
Lets modify writable descriptor for an existing property from true to false and see what happens
var myObj = { key1: 10 };// key1 is writable by default, and we can check that
console.log(Object.getOwnPropertyDescriptor(myObj, "key1").writable); // true// Since key1 is writable we can re-asssign it
myObj.key1 = 20;console.log(myObj.key1); // 20,
But lets make key1 non-writable this time and then we shouldnt be able to reassign it. Lets create a property using Object.defineProperty() this time and if you remember by using Object.defineProperty() to create new property will have their descriptors false by default.
var myObj = {};Object.defineProperty(myObj, "key1", {value: 10});console.log(myObj.key1); // 10// key1 is Non-writable by default, and we can check it
console.log(Object.getOwnPropertyDescriptor(myObj, "key1").writable); // false// Since key1 is non-writable, we cant re-asssign it
myObj.key1 = 20; // Throws TypeError in strict mode
console.log(myObj.key1); // 10
One important thing to note regarding writable descriptor is, If the property value is an object itself then inner properties will be writable. In other words descriptors are not set for inner properties.
var myObj = {};Object.defineProperty(myObj, "key1", {value: {innerKey: "HI"}});// key1 is non-writable
myObj.key1 = "morpheus"; // Throws TypeError in strict modeconsole.log(myObj.key1); // {innerKey: "HI"}// But innerKey is writable
myObj.key1.innerKey = "Hello";console.log(myObj.key1.innerKey); // "Hello"
So in order to make innerKey non-writable we ll have to modify its writable descriptor to false as well.
Object.defineProperty(myObj.key1, "innerKey", {writable: false});// innerKey is non-writable now
myObj.key1.innerKey = 30;
console.log(myObj.key1); // "HI"
Conclusion: Non-writable properties cannot be re-assigned but it can be mutated with .(dot) operator, therefore writable descriptor must be set to false recursively for properties with objects as value to have completely non-writable object.
configurable
This one is the most powerful among the 3, coz if set to false then that property becomes non-configurable and we cannot do these things then.
- We cannot modify any property descriptor again (Only one exception being setting writable true to false).
- We cannot delete a non-configurable property and it will throw TypeError in strict mode.
Its evident that configurable is our final strike when it comes to do a complete lockdown of an object property. Lets see the examples
var myObj= {};Object.defineProperty( myObj, 'key1', {value: 10});// Cant delete non-configurable property
delete myObj.key1;console.log(myObj.key1); // 10Object.defineProperty(myObj, 'key1', { enumerable: true });
// throws a TypeErrorObject.defineProperty(myObj, 'key1', { value: 12 });
// throws a TypeErrorObject.defineProperty(myObj, 'key1', { writable: true });
// throws a TypeErrorObject.defineProperty(myObj, 'key1', { configurable: true });
// throws a TypeError
There is only one exception that if writable is true then we can set to false
var myObj= {};Object.defineProperty( myObj, 'key1', {
value: 10,
writable: true
});Object.defineProperty(myObj, 'key1', { writable: false});console.log(Object.getOwnPropertyDescriptor(myObj, "key1"));
/*{ value: 10,
writable: false,
enumerable: false,
configurable: false }*/
Conclusion: Configurable descriptor helps to locks down the object property, prevents deletion and is crucial in making a read-only object.
Back to our Immutability goal
If you have made this far then its time to reward yourself by achieving the Immutabilty goal that we set in our first example using the native Object methods and descriptors we just learned. We will prevent all those 4 modifications from happening to our object that we declared in the beginning.
var myObj = {
key1: 10,
key2: { innerKey: 100 }
};
- Prevent modifying of existing property values (make read-only)
Object.defineProperty(myObj, "key1", {writable: false});
Object.defineProperty(myObj, "key2", {writable: false});
Object.defineProperty(myObj.key2, "innerKey", {writable: false});myObj.key1 = 20; // TypeError in strict mode
myObj.key2 = 40; // TypeError in strict mode
myObj.key2.innerKey = 40; // TypeError in strict modeconsole.log(myObj); // {key1: 10, key2: {innerKey: 100}}
2. Prevent addition of new properties to our existing object
Javascript objects are extensible by default meaning new properties can be added to it, but we can restrict that by using Object.preventExtensions() method. Note that restricting extension is an irreversible operation so we can never add extra properties to that object again.
Object.preventExtension(myObj);// Now new properties cannot be added to myObjmyObj.newKey = "Hello";
// thows TypeError in strict mode console.log(myObj); // {key1: 10, key2: {innerKey: 100}}
3. Prevent deletion of existing properties
Object.defineProperty(myObj, "key1", {configurable: false});
Object.defineProperty(myObj, "key2", {configurable: false});
Object.defineProperty(myObj.key2, "innerKey", {configurable: false});delete myObj.key1; // TypeError in strict mode
delete myObj.key2; // TypeError in strict mode
delete myObj.key2.innerKey; // TypeError in strict modeconsole.log(myObj); // {key1: 10, key2: {innerKey: 100}}
4. Prevent re-assigning of our object
To do this we must first realize that our object “myObj” is just a property in the global scope for the object global ( in Node) or window (in Browser), hence we can make it non-writable by defining it ourselves.
// If in node then use "global" else use "window"
var globalScope = typeof global === "object" ? global : window;Object.defineProperty(globalScope, "myObj", {
value: {
key1: 10,
key2: {innerKey: 100}
},
enumerable: true,
writable: false,
configurable: true
});// Now myObj is non-writablemyObj = "Morpheus" // throws TypeError in strict modeconsole.log(myObj); // {key1: 10, key2: {innerKey: 100}}
Or we can use the simple ES6 “const” keyword to do same.
const myObj = {
key1: 10,
key2: { innerKey: 100 }
};myObj = "Morpheus"; // TypeError: Assignment to constant variable
Ending Notes:
This article was actually an attempt to look into property descriptors using object immutability as an excuse. Providing good immutable data structures in native Javascript is a big challange and hopefully will be resolved in upcoming Ecmascript versions.
Now lets look into the cool functions that take care of this descriptor manipulation in the second article here, by the end of both articles reader will have a solid understanding of almost all the methods in Object constructor.
References:
- Mozilla docs
- Deep dive into Javascript Property Descriptors -By AbdulFattah Popoola
- Javascript properties are enumerable, writable and configurable -By Javier Marquez
- Object properties in Javascript -By Dr. Axel Rauschmayer