A look at object internals and the secret lives of objects
The Mastering the Arcane Art of JavaScript-mancy series are my humble attempt at bringing my love for JavaScript to all other C# developers that haven’t yet discovered how awesome this language and its whole ecosystem are. These articles are excerpts of the super duper awesome JavaScript-Mancy book a compendium of all things JavaScript for C# developers.
So far in this series we’ve focused a lot in how to work with objects in JavaScript and about different paradigms of object oriented programming that are supported in this beautiful language. In this and the upcoming articles we’re going to do something different, we’re going to dive into the inner workings of objects and different metaprogramming techniques that will give you more control over how you define and operate them: the ES5 Object APIs, ESnext decorators, ES6 Proxies and the ES6 Reflection API.
Follow me as we dive into the depths of object internals in JavaScript and unveil the deepest secrets of objects!
All your Objects Are Belong to Object
All objects in JavaScript descend from Object
(Much in the same way that all C# objects descend from System.Object
). This means that all objects inherit properties and methods from Object.prototype
through the prototypical chain that we described in previous articles. Hence, augmenting the Object.prototype
object with new properties and methods results in all objects having access to these new properties and methods.
In addition to acting as a base object, the Object
constructor has a number of static methods that give you a greater control over your objects and let you obtain additional information about them. Using these methods you can, for instance, define whether a given property is read-only or enumerable, define whether an object is immutable or not, or find out which is the prototype of a given object.
Defining Properties with Object.defineProperty
Object.defineProperty
allows you to define new properties and methods. This method expects you to send a series of property descriptors to describe the characteristics of the new properties and methods that you want to create. But how do these property descriptors look like?
Imagine that you have a new object goat
:
var goat = {}
You can augment it with a property hitPoints
that describes the proverbial life essence of the goat
by using the following descriptor:
Object.defineProperty(goat, 'hitPoints', {
/* object descriptor */
value: 50,
writable: true,
enumerable: true,
configurable: true,
})
This results in the goat
object now having a hitPoints
property with value 50
. Behold!
console.log(`Goat has ${goat.hitPoints} hit points`)
// => Goat has 50 hit points
Ok, so we have added a property to an object. What’s new with that? Well, the important bit in the previous example is the object descriptor. It provides some hints as to a higher degree of control we don’t have when we just augment an object with a property as in:
goat.hitPoints = 50
Let’s take a look at object descriptors and go through each of their properties.
Property Descriptors: Data and Accessor Descriptors
A property descriptor is an object that describes how a property or method within an object should behave. JavaScript has two types of property descriptors: data and accessor descriptors.
You can use a data descriptor to describe a normal property or method within an object. The descriptor we used in the previous example for the hitPoints
property is a great example of a data descriptor:
{
/* data descriptor */
value: 50,
writable: true,
enumerable: true,
configurable: true
}
In addition to data descriptors we have accessor descriptors. Accessor descriptors represent property getters and setters and trade the value
and writable
properties for the get
and set
properties.
For instance, let’s say that we want to enforce some invariants in our hp
property, that is, we want to add some validation to ensure that the hp property doesn’t get an invalid or inconsistent value. Again we start with a dangerous predator, the sheep
:
var sheep = {}
In this ocassion we will define a backing field _hitPoints
using a data descriptor as before:
Object.defineProperty(sheep, '_hitPoints', {
/* accessor descriptor */
value: 50,
writable: true,
enumerable: false, // look here!
configurable: true,
})
Notice how we set the enumerable
property to false
to denote that we don’t want this property to appear when you enumerate over the properties of this object (making it a little harder to reach even though is completely public at this point). Now we can define the property hitPoints
as a getter/setter using an accessor descriptor:
Object.defineProperty(sheep, 'hitPoints', {
/* data descriptor */
get() {
return this._hitPoints
},
set(value) {
if (value === undefined || value === null || value < 0)
throw new Error(
`Invalid value ${value}! Hit points` +
`should be a number greater than 0!`
)
this._hitPoints = value
},
enumerable: true,
configurable: true,
})
The get
method just exposes the _hitPoints
value as is, and the set
method contains our validation logic. We can test it and verify that it works as we expect.
When you provide a reasonable value the property behaves normally:
console.log(`Sheep has ${sheep.hitPoints} hit points`)
// => Sheep has 50 hit points
// Let's try something simple
sheep.hitPoints = 10
console.log(`Sheep has ${sheep.hitPoints} hit points`)
// => Sheep has 10 hit points
But when you break the invariants you’ll get a well described exception:
// Now let's go into the danger zone
try {
sheep.hitPoints -= 20
} catch (e) {
console.log(e.message)
// => Invalid value -10! Hit points should
// be a number greater than 0!
}
Oops! And…
// And more danger!
try {
sheep.hitPoints = undefined
} catch (e) {
console.log(e.message)
// => Invalid value undefined! Hit points
// should be a number greater than 0!
}
Ouch!… Alright, let’s take a look at each one of the properties and what they mean. Both data and accessor descriptors share two properties:
- configurable: if
true
it let’s you modify the property descriptor and delete the property from a given object. It defaults tofalse
. - enumerable: if
true
it let’s you enumerate the property. If you are not familiar with the concept of enumerability in JavaScript it means that the property shows up when traversing the properties of an object using afor...in
loop. It defaults tofalse
.
Data descriptors, which describe properties and methods, have these two additional properties:
- writable: if true it let’s you modify the value of the property. It defaults to
false
. - value: contains the value of the property and it can be any JavaScript expression. If it is a function then the resulting property is a method. It defaults to
undefined
.
Accessor descriptors, which describe getters and setters, have these other two additional properties:
- get: function that represents a getter for the property. If it is undefined the property returns
undefined
when you try to retrieve its value using the dot notation. A property with noget
method and aset
method becomes effectively a set-only property. - set: function that represents a setter for the property. If it is undefined the property can’t be set. The function receives as argument a single value that is assigned to the property. A property with a
get
method and noset
method becomes effectively a read-only property.
Defining Multiple Properties with Object.defineProperties
In addition to being able to define your properties one by one with Object.defineProperty
the Object
object also exposes a method defineProperties
that allows to extend an object with many properties at once.
Let’s say that we want to militarize and weaponize our most dangerous minion, the goat
. We can extend it with two new properties weapons
and armor
:
Object.defineProperties(goat, {
weapons: {
value: ['knife', 'katana', 'hand-trebuchet'],
enumerable: true,
writable: true,
configurable: true,
},
armor: {
value: ['templar helmet', 'platemail'],
enumerable: true,
writable: true,
configurable: true,
},
})
console.log(goat.weapons)
// => ["knife", "katana", "hand-trebuchet"]
console.log(goat.armor)
// => ["templar helmet", "platemail"]
It is good to note how the second argument to the Object.defineProperties
, the one defining the properties to extend the target object with, is an object with properties and not an array as you may have expected.
A goat with a helmet and platemail, the beauty of fiction…
Create Objects With Object.create And Property Descriptors
By this point you’re no longer a stranger to the Object.create
method. We have used it in previous articles to create new objects with a specific prototype and even with traits and object composition. This second use gives us a hint as to the true capabilities of Object.create
, take a look at this example from earlier articles:
function MinionWithPosition() {
let methods = {
toString() {
return 'minion'
},
}
let minion = Object.create(
/* prototype */ methods,
/* traits (object properties) */ TPositionable
)
return minion
}
The example above represents a factory MinionWithPosition
that makes use of Object.create
to create a object minion
with:
- The
methods
object as prototype - A bunch of properties and methods defined by the
TPositionable
trait
But how are these trait properties and methods defined? Yes! With property descriptors! Take a look at this:
console.log(Trait({ weapons: ['knife'] }).weapons)
// => Object {
// value: ['knife'],
// writable: true,
// enumerable: true,
// configurable: true
// }
The traits library Trait
function decomposes your object into property descriptors that it can then use to manage things like composability, required properties, conflict resolution, etc.
Therefore the second argument of Object.create
is an object whose properties are property descriptors. A goat
example equivalent with Object.create
would look like this:
let anotherGoat = Object.create(Object.prototype, {
_hitPoints: {
/* accessor descriptor */
value: 50,
writable: true,
enumerable: false, // look here!
configurable: true,
},
hitPoints: {
/* data descriptor */
get() {
return this._hitPoints
},
set(value) {
if (value === undefined || value === null || value < 0)
throw new Error(
`Invalid value ${value}! Hit ` +
`points should be a number greater than 0!`
)
this._hitPoints = value
},
enumerable: true,
configurable: true,
},
weapons: {
value: ['knife', 'katana', 'hand-trebuchet'],
enumerable: true,
writable: true,
configurable: true,
},
armor: {
value: ['templar helmet', 'platemail'],
enumerable: true,
writable: true,
configurable: true,
},
})
Of course this is not going to be how you define objects in your day to day programming but the Traits library provides some inspiration regarding when property descriptors and the various Object
methods can be useful: Metaprogramming.
Metaprogramming
Metaprogramming is a programming technique where you have the ability to treat programming constructs as the data of your program. That is, metaprogramming is the art of programming programming (BOOM! - pause for effect).
Since all things meta can be pretty daunting at first, let’s go back to our earlier examples in this article to explain metaprogramming through an example. In the example with Object.defineProperty
and our mighty defender the goat
, we have taken a programming construct, the property hitPoints
, something that is typically part of programming itself:
goat.hitPoints = 50
And we have represented it as a piece of data (a property descriptor):
{
hitPoints: {
value: 50,
writable: true,
enumerable: true,
configurable: true
}
}
Then we have used that data as part of a new program to extend the object goat
with new properties:
Object.defineProperties(goat, {
hitPoints: {
value: 50,
writable: true,
enumerable: true,
configurable: true,
},
})
This is a simple example of a metaprogramming. We’ve taken an everyday feature of our programs - properties - and we’ve written a small program that operates on object properties themselves.
Other examples of metaprogramming can often be seen in JavaScript web frameworks and libraries. The first example that you found out about in this article was Traits.js. Traits.js makes extensive use of property descriptors to define a new way to do object oriented programming in JavaScript, one that allows object composition with additional guarantees like required properties and conflict resolution. The popular web framework vue.js uses a similar technique in their change detection algorithm by replacing all the properties of your model for getters and setters using Object.defineProperty
. This allows the framework to observe changes in your model properties and reflect them in the user interface.
These two use cases of property descriptors and Object.defineProperties
are pretty awesome aren’t they? I’m looking forward to see what you can do with this newfound knowledge (evil laughter).
Have an awesome day!
Written by Jaime González García , dad, husband, software engineer, ux designer, amateur pixel artist, tinkerer and master of the arcane arts. You can also find him on Twitter jabbering about random stuff.