Flexible Objects
Earlier, we described objects as simple collections of key-value pairs called properties. Objects are often used in this way as basic map-like structures to store information, but JavaScript also allows for more dynamic and customizable behavior.
In most of this chapter’s examples, we will use object literal
notation, which uses curly braces {}
to enclose a list of property
definitions. It is important to note that, with object literals, a new, unique
object instance is created in memory each time its expression is evaluated. Even
if two literals look identical,
let obj1 = { a: 123 };
let obj2 = { a: 123 };
obj1 === obj2; // false
// obj1 and obj2 are two distinct objects in memory
or if the same literal expression is run twice,
function foo() {
return { a: 123 };
}
let obj1 = foo();
let obj2 = foo();
obj1 === obj2; // false
a different instance is created each time.
Using Object Literals
Computed Property Names
We’ve already seen how to create objects using object literal syntax:
let obj = {
a: 42,
b: 'abc',
c: true,
longPropertyName: 123,
};
We can also assign computed property names using square brackets to
create properties whose keys are not known ahead of time. Similarly to the
square bracket notation used to access properties on an object (e.g., obj[40 + 2]
to access property '42'
on object obj
), the expression inside the square
brackets will be evaluated and coerced to a string in order to determine the
property name.
let foo = 'b';
let bar = 'Name';
let obj = {
a: 42,
[foo]: 'abc',
c: true,
['long' + 'Property' + bar]: 123,
};
This notation must also be used to specify property names which are symbols rather than strings.
let sym = Symbol();
let obj = {
[sym]: 'a symbol-keyed property',
};
obj[sym]; // 'a symbol-keyed property'
Shorthand Property Names
Sometimes we may want to encapsulate local lexical variables in an object using the same variable names.
let a = 42, b = 'abc', c = true;
let obj = { a: a, b: b, c: c };
If the property we want to create has the same name as a lexically available variable, we can use shorthand to assign that property in the object literal.
let a = 42, b = 'abc', c = true;
let obj = { a, b, c }; // { a: 42, b: 'abc', c: true }
Method Shorthand
Object properties can contain any value, including a reference to a function.
let obj = {
add: function(a, b) {
return a + b;
},
};
obj.add(1, 2); // 3
Object properties which are functions are commonly referred to as methods. Method shorthand lets us define a function property in object literals using only its name and parentheses with any parameters.
let obj = {
add(a, b) {
return a + b;
},
};
obj.add(1, 2); // 3
Accessor Properties: Getters and Setters
All of the properties we’ve used so far have been data properties, which are direct one-to-one mappings from a given key (name) to a value. By default, when we create properties on objects, they are data properties.
let obj = { foo: 123 };
obj.bar = 'abc';
obj.foo; // 123
obj.bar; // "abc"
Object properties can be defined in a different paradigm: accessor properties allow us to specify custom behavior to intercept and handle reading from and writing to a property.
We can define a getter function to dynamically calculate the value of a property when it is accessed – a pattern commonly referred to as computed properties (not to be confused with computed property names as described earlier). The value returned by the getter function will be used as the value of the property lookup.
let person = {
firstName: 'Alice',
lastName: 'Programmer',
get fullName() {
return this.firstName + ' ' + this.lastName;
},
};
person.fullName; // "Alice Programmer"
Notice that the function was invoked without the use of parentheses. From the perspective of the code using the object, it is not apparent whether accessing a property will read a data value directly or invoke a getter function.
Similarly, a setter function can dynamically intercept a value being assigned to an object property. Setter functions accept one parameter, which will be bound to the value being assigned to the property.
let person = {
set fullName(value) {
let [first, last] = value.split(' ');
this.firstName = first;
this.lastName = last;
},
};
person.fullName = 'Bob Programmer';
person.firstName; // "Bob"
person.lastName; // "Programmer"
Both a getter and setter may be set for accessor properties. However, neither a getter nor setter can coexist with a normal data property of the same name on the same object.
let obj = {
get a() { return 123; },
a: 'abc'
};
obj.a; // "abc"
Property Descriptors
Each property of an object has a few different attributes which specify the kinds of behaviors allowed for that property. Normal data properties have four attributes:
value
- the value of the property
writable
- (boolean) whether the value of the property can be changed using an assignment operator
configurable
- (boolean) whether the property type (data or accessor) can be changed, and if the property can be deleted from its object
enumerable
- (boolean) whether the property appears during enumeration of the object's properties
Together, these four attributes constitute a property’s descriptor.
We can observe the values of these attributes using the static method
Object.getOwnPropertyDescriptor
. It accepts two arguments: the object on which
to look, and the string (or symbol) of the property name. If the property exists
on the specified object, it returns an object called a property
descriptor which lists the property’s attributes and values. (If the
property does not exist, it will return undefined
).
By default, data properties created using object literal syntax or direct assignment are configurable, enumerable, and writable.
let obj = { a: 123 };
Object.getOwnPropertyDescriptor(obj, 'a');
// { value: 123, writable: true, enumerable: true, configurable: true }
obj.b = 'abc';
Object.getOwnPropertyDescriptor(obj, 'b');
// { value: "abc", writable: true, enumerable: true, configurable: true }
We can also define a new property and its accompanying attributes, or modify the
attributes of an existing property, using the static method
Object.defineProperty
. The method accepts three arguments: the object on which
to define the property, the name (or symbol) of the property, and a descriptor,
which must itself be an object. The method will return a reference to the given
target object.
If any of the attributes are not present on the provided descriptor, they
default to false
(and value: undefined
).
let obj = {};
Object.defineProperty(obj, 'a', { value: 123 });
Object.getOwnPropertyDescriptor(obj, 'a');
// { value: 123, writable: false, enumerable: false, configurable: false }
writable
If a property is not writable, then its value cannot be changed using assignment operators. An assignment expression will still evaluate to the right-hand operand of the assignment operator, but the value of the property itself will not change.
let obj = Object.defineProperty({}, 'foo', { value: 123, writable: false });
obj.foo; // 123
obj.foo = 'abc'; // "abc"
obj.foo; // 123
enumerable
Properties which are enumerable will appear in contexts where an object’s
properties are enumerated over, such as in for..in
loops, with the object
spread operator, or from the static methods Object.assign()
, Object.keys()
,
Object.values()
, and Object.entries()
(each discussed more in
Non-enumerable properties can come in handy if we want to treat objects as key-value collections – and thus allow their entries to be enumerated over – but need to also store some other state or functionality on the same object that is not necessarily part of the collection itself.
We can explicitly check if an object’s property is enumerable using the
inherited method Object.prototype.propertyIsEnumerable
.
let obj = { foo: 123 };
obj.propertyIsEnumerable('foo'); // true
let obj2 = Object.defineProperty({}, 'bar', { value: 123 });
obj2.propertyIsEnumerable('bar'); // false
configurable
A property’s attributes (other than writable
) are allowed to be changed, and
the property is allowed to be deleted, only if it is configurable.
let obj = { foo: 123 };
Object.defineProperty(obj, 'bar', { value: 'abc', configurable: false });
delete obj.foo; // true
delete obj.bar; // false
obj.foo; // undefined
obj.bar; // "abc"
Object.defineProperty
can also be used to modify an existing, configurable
property. If used on an existing property, the descriptor provided will be
“merged” with the existing descriptor; that is, attributes defined on the new
descriptor will overwrite the old attribute values, but attributes not defined
on the new descriptor will stay the same.
let obj = Object.defineProperty({}, 'foo', { value: 123, configurable: true });
obj.foo = 'abc'; // "abc"
obj.foo; // 123
// The property must be re-configured in order to make it writable again
Object.defineProperty(obj, 'foo', { writable: true });
obj.foo = 'abc'; // "abc"
obj.foo; // "abc"
obj.propertyIsEnumerable('foo'); // true
// Note: the second `defineProperty` did not overwrite
// obj.foo's existing `value`, `configurable`, or `enumerable` attributes
If a property is non-configurable but is writable, its value can be changed, and
its writable
attribute may also be changed from true
to false
.