In this blog post, we take a closer look at how the ECMAScript specification sees JavaScript objects. In particular, properties are not atomic in the spec, but composed of multiple attributes (think fields in a record). Even the value of a data property is stored in an attribute!
In the ECMAScript specification, an object consists of:
This is how the specification describes internal slots (the emphasis is mine):
undefined.[[ ]].There are two kinds of internal slots:
| Internal data slot | Type |
|---|---|
[[Prototype]] |
null ¦ object |
[[Extensible]] |
boolean |
[[PrivateFieldValues]] |
List of entries |
Descriptions for these data slots:
[[Prototype]] stores the prototype of an object.
Object.getPrototypeOf() and Object.setPrototypeOf()[[Extensible]] indicates if it is possible to add properties to an object.
false via Object.preventExtensions().[[PrivateFieldValues]] is used to manage private class fields.The key of a property is either:
There are two kinds of properties and they have different attributes:
value holds any JavaScript value.get, the latter in the attribute set.The following table lists all property attributes.
| Kind of property | Name and type of attribute | Default value |
|---|---|---|
| Data property | value: any |
undefined |
writable: boolean |
false |
|
| Accessor property | get(): any |
undefined |
set(v: any): void |
undefined |
|
| All properties | configurable: boolean |
false |
enumerable: boolean |
false |
We have already encountered the attributes value, get, and set. The other attributes work as follows:
writable determines if the value of a data property can be changed.configurable determines if the attributes of a property can be changed. If it is false, then:
value.writable from true to false. The rationale behind this anomaly is historical: Property .length of Arrays has always been writable and non-configurable. Allowing its writable attribute to be changed enables us to freeze Arrays.enumerable influences some operations (such as Object.assign()). If it is false, then those operations ignore the property.A property descriptor encodes the attributes of a property as a JavaScript object. Their TypeScript interfaces look as follows.
interface DataPropertyDescriptor {
value?: any;
writable?: boolean;
configurable?: boolean;
enumerable?: boolean;
}
interface AccessorPropertyDescriptor {
get?(): any;
set?(v: any): void;
configurable?: boolean;
enumerable?: boolean;
}
type PropertyDescriptor = DataPropertyDescriptor | AccessorPropertyDescriptor;
The question marks indicate that each property is optional. If you omit a property when passing a descriptor to an operation, then its default value is used.
The following code retrieves the object descriptor for the data property first:
const obj = {
first: 'Jane',
};
assert.deepEqual(
Object.getOwnPropertyDescriptor(obj, 'first'),
{
value: 'Jane',
writable: true,
enumerable: true,
configurable: true,
});
In the next example, we retrieve the property descriptor for the getter fullName:
const desc = Object.getOwnPropertyDescriptor.bind(Object);
const jane = {
first: 'Jane',
last: 'Doe',
get fullName() {
return this.first + ' ' + this.last;
},
};
assert.deepEqual(
Object.getOwnPropertyDescriptor(jane, 'fullName'),
{
get: desc(jane, 'fullName').get, // (A)
set: undefined,
enumerable: true,
configurable: true
});
Using desc() in line A is a work-around so that .deepEqual() works.
You can also create new properties via property descriptors:
const car = {};
Object.defineProperty(car, 'color', {
value: 'blue',
writable: true,
enumerable: true,
configurable: true,
});
assert.deepEqual(
car,
{
color: 'blue',
});
If an own property already exists, then defining it via a descriptor changes that property. On one hand that allows us to use Object.defineProperty() like assignment:
const car = {
color: 'blue',
};
Object.defineProperty(car, 'color', {
value: 'green',
writable: true,
enumerable: true,
configurable: true,
});
assert.deepEqual(
car,
{
color: 'green',
});
On the other hand, we can also use Object.defineProperty() to turn a data property into a getter (and vice versa):
const car = {
color: 'blue',
};
let getterCallCount = 0;
Object.defineProperty(car, 'color', {
get() {
getterCallCount++;
return 'red';
},
});
assert.equal(car.color, 'red');
assert.equal(getterCallCount, 1);
If an inherited property is read-only, then we can’t use assignment to change it. The rationale is that overriding an inherited property by creating an own property can be seen as non-destructively changing the inherited property. Arguably, if a property is non-writable, we shouldn’t be able to do that.
Let’s look at an example:
const proto = Object.defineProperties({}, {
prop: {
value: 1,
writable: false,
}
});
const obj = Object.create(proto);
assert.throws(
() => obj.prop = 2,
/^TypeError: Cannot assign to read only property 'prop'/);
We can’t change the property via assignment. But we can still create an own property by defining it:
Object.defineProperty(obj, 'prop', {value: 2});
assert.equal(obj.prop, 2);
Accessor properties that don’t have a setter are also considered to be read-only:
const proto = Object.defineProperties({}, {
prop: {
get() {
return 1;
}
}
});
const obj = Object.create(proto);
assert.throws(
() => obj.prop = 2,
'TypeError: Cannot set property prop of #<Object> which has only a getter');
The following functions allow you to work with property descriptors:
Object.defineProperty(obj: object, key: string|symbol, propDesc: PropertyDescriptor): object
Creates or changes a property on obj whose key is key and whose attributes are specified via propDesc. Returns the modified object.
const obj = {};
const result = Object.defineProperty(
obj, 'happy', {
value: 'yes',
writable: true,
enumerable: true,
configurable: true,
});
// obj was returned and modified:
assert.equal(result, obj);
assert.deepEqual(obj, {
happy: 'yes',
});
Object.defineProperties(obj: object, properties: {[k: string|symbol]: PropertyDescriptor}): object
The batch version of Object.defineProperty(). Each property of properties holds a property descriptor. The keys of the properties and their values tell Object.defineProperties what properties to create or change on obj.
const address1 = Object.defineProperties({}, {
street: { value: 'Evergreen Terrace', enumerable: true },
number: { value: 742, enumerable: true },
});
Object.create(proto: null|object, properties?: {[k: string|symbol]: PropertyDescriptor}): object
First, creates an object whose prototype is proto. Then, if the optional parameter properties has been provided, adds properties to it – in the same manner as Object.defineProperties(). Finally, returns the result. For example, the following code snippet produces the same result as the previous snippet:
const address2 = Object.create(Object.prototype, {
street: { value: 'Evergreen Terrace', enumerable: true },
number: { value: 742, enumerable: true },
});
assert.deepEqual(address1, address2);
Object.getOwnPropertyDescriptor(obj: object, key: string|symbol): undefined|PropertyDescriptor
Returns the descriptor of the own (non-inherited) property of obj whose key is key. If there is no such property, undefined is returned.
assert.deepEqual(
Object.getOwnPropertyDescriptor(Object.prototype, 'toString'),
{
value: {}.toString,
writable: true,
enumerable: false,
configurable: true,
});
assert.equal(
Object.getOwnPropertyDescriptor({}, 'toString'),
undefined);
Object.getOwnPropertyDescriptors(obj: object): {[k: string|symbol]: PropertyDescriptor}
Returns an object where each property key 'k' of obj is mapped to the property descriptor for obj.k. The result can be used as input for Object.defineProperties() and Object.create().
const desc = Object.getOwnPropertyDescriptor.bind(Object);
const propertyKey = Symbol('propertyKey');
const obj = {
[propertyKey]: 'abc',
get count() { return 123 },
};
assert.deepEqual(
Object.getOwnPropertyDescriptors(obj),
{
[propertyKey]: {
value: 'abc',
writable: true,
enumerable: true,
configurable: true
},
count: {
get: desc(obj, 'count').get, // (A)
set: undefined,
enumerable: true,
configurable: true
}
});
Using desc() in line A is a work-around so that .deepEqual() works.
Object.getOwnPropertyDescriptors() Object.getOwnPropertyDescriptors(): copying properties into an object Since ES6, JavaScript already has had a tool method for copying properties: Object.assign(). However, this method uses simple get and set operations to copy a property whose key is key:
target[key] = source[key];
That means that it only creates a faithful copy of a property if:
writable is true and its attribute enumerable is true (because that’s how assignment creates properties).The following example illustrates this limitation. Object source has a setter whose key is data.
const source = {
set data(value) {
this._data = value;
}
};
const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
Object.getOwnPropertyDescriptor(source, 'data'),
{
get: undefined,
set: desc(source, 'data').set,
enumerable: true,
configurable: true,
});
// Because there is only a setter, property `data` exists,
// but has the value `undefined`.
assert.equal('data' in source, true);
assert.equal(source.data, undefined);
If we use Object.assign() to copy property data, then the accessor property data is converted to a data property:
const target1 = {};
Object.assign(target1, source);
assert.deepEqual(
Object.getOwnPropertyDescriptor(target1, 'data'),
{
value: undefined,
writable: true,
enumerable: true,
configurable: true,
});
Fortunately, using Object.getOwnPropertyDescriptors() together with Object.defineProperties() does faithfully copy the property data:
const target2 = {};
Object.defineProperties(
target2, Object.getOwnPropertyDescriptors(source));
assert.deepEqual(
Object.getOwnPropertyDescriptor(target2, 'data'),
{
get: undefined,
set: desc(source, 'data').set,
enumerable: true,
configurable: true,
});
super A method that uses super is firmly connected with its home object (the object it is stored in). There is currently no way to copy or move such a method to a different object.
Object.getOwnPropertyDescriptors(): cloning objects Shallow cloning is similar to copying properties, which is why Object.getOwnPropertyDescriptors() is a good choice here, too.
To create the clone, we use Object.create():
const original = {
set data(value) {
this._data = value;
}
};
const clone = Object.create(
Object.getPrototypeOf(original),
Object.getOwnPropertyDescriptors(original));
assert.deepEqual(original, clone);