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!
- The structure of objects
- Internal slots
- Property keys
- Property attributes
-
Property descriptors
- Retrieving descriptors for properties
- Creating new properties via descriptors
- Changing existing properties via descriptors
- Pitfall: inherited read-only properties can’t be assigned to
- API: property descriptors
- Use cases for
Object.getOwnPropertyDescriptors[]
-
Object.getOwnPropertyDescriptors[]
: copying properties into an object -
Object.getOwnPropertyDescriptors[]
: cloning objects
-
The structure of objects
In the ECMAScript specification, an object consists of:
- Internal slots, which are storage locations that are not accessible from JavaScript, only to operations in the specification.
- A collection of properties. Each property associates a key with attributes [think fields in a record].
Internal slots
This is how the specification describes internal slots [the emphasis is mine]:
- Internal slots correspond to internal state that is associated with objects and used by various ECMAScript specification algorithms.
- Internal slots are not object properties and they are not inherited.
- Depending upon the specific internal slot specification, such state may consist of values:
- of any ECMAScript language type or
- of specific ECMAScript specification type values.
- Unless explicitly specified otherwise, internal slots are allocated as part of the process of creating an object and may not be dynamically added to an object.
- Unless specified otherwise, the initial value of an internal slot is the value
undefined
. - Various algorithms within this specification create objects that have internal slots. However, the ECMAScript language provides no direct way to associate internal slots with an object.
- Internal methods and internal slots are identified within this specification using names enclosed in double square brackets
[[ ]]
.
There are two kinds of internal slots:
- Method slots for manipulating objects [getting properties, setting properties, etc.]
- Data slots with storage [listed in the table below]
[[Prototype]]
| null ¦ object
|
[[Extensible]]
| boolean
|
[[PrivateFieldValues]]
| List of entries |
Descriptions for these data slots:
[[Prototype]]
stores the prototype of an object.- Can be changed via
Object.getPrototypeOf[]
andObject.setPrototypeOf[]
- Can be changed via
[[Extensible]]
indicates if it is possible to add properties to an object.- Can be set to
false
viaObject.preventExtensions[]
.
- Can be set to
[[PrivateFieldValues]]
is used to manage private class fields.
Property keys
The key of a property is either:
- A string
- A symbol
Property attributes
There are two kinds of properties and they have different attributes:
- A data property stores data. Its attributes
value
holds any JavaScript value. - An accessor property has a getter function and/or a setter function. The former is stored in the attribute
get
, the latter in the attributeset
.
The following table lists all property attributes.
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 isfalse
, then:- You cannot delete the property.
- You cannot change a property from a data property to an accessor property or vice versa.
- You cannot change any attribute
other than
value
. - However, one more attribute change is allowed: You can change
writable
fromtrue
tofalse
. The rationale behind this anomaly is historical: Property.length
of Arrays has always been writable and non-configurable. Allowing itswritable
attribute to be changed enables us to freeze Arrays.
enumerable
influences some operations [such asObject.assign[]
]. If it isfalse
, then those operations ignore the property.
Property descriptors
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.
Retrieving descriptors for properties
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.
Creating new properties via descriptors
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',
}];
Changing existing properties via descriptors
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];
Pitfall: inherited read-only properties can’t be assigned to
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 # which has only a getter'];
API: property descriptors
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 iskey
and whose attributes are specified viapropDesc
. 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 ofproperties
holds a property descriptor. The keys of the properties and their values tellObject.defineProperties
what properties to create or change onobj
.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 parameterproperties
has been provided, adds properties to it – in the same manner asObject.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 iskey
. 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'
ofobj
is mapped to the property descriptor forobj.k
. The result can be used as input forObject.defineProperties[]
andObject.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.
Use cases for 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:
- Its attribute
writable
istrue
and its attributeenumerable
istrue
[because that’s how assignment creates properties]. - It is a data property.
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,
}];
Pitfall: copying methods that use 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];