Objects and Properties
This chapter assumes you already know JavaScript's primitive types, how let/const scoping works, and how functions and this behave. Here we focus on the object model itself: how properties are stored, looked up, mutated, and protected, and where the intuitive model ("a bag of key/value pairs") breaks down.
Property Descriptors, Not Just Values
Every property is backed by a property descriptor with value, writable, enumerable, and configurable flags. Assignment syntax (obj.x = 1) creates writable, enumerable, configurable data properties. Object.defineProperty gives fine-grained control:
const obj = {};
Object.defineProperty(obj, 'id', {
value: 42,
writable: false,
enumerable: false,
configurable: false
});
obj.id = 99; // silently fails (throws in strict mode)
console.log(Object.keys(obj)); // [] — id is not enumerableThe failure mode to watch for: configurable: false is a one-way door. You cannot later make the property writable or removable. In strict mode or modules, writing to a non-writable property throws a TypeError rather than failing silently.
Accessor Properties
A descriptor may instead have get/set functions. Accessors cost more than plain data properties (the engine can no longer use the fast inline-cache path as cheaply) and can be surprising:
const account = {
get balance() { return this._balance ?? 0; },
set balance(v) {
if (typeof v !== 'number') throw new TypeError('number required');
this._balance = v;
}
};Note the trade-off: accessors look like plain fields from the call site, so a future refactor from field to accessor changes evaluation order and cost invisibly.
Internal Methods and Invariants
Property access goes through the internal [[Get]]/[[Set]] operations, which honor the prototype chain and getter semantics. The spec's invariants are why some operations throw: a non-configurable, non-writable property cannot be redefined even via Object.defineProperty, and Object.freeze makes everything non-writable and non-configurable. Shallow-ness is the classic gotcha: Object.freeze(obj) does not freeze nested objects.
Mutation, Identity, and copies
Objects are reference values; assignment copies the reference, never the object. Object.assign and spread create shallow copies only.
const a = { nested: { x: 1 } };
const b = { ...a };
b.nested.x = 2;
console.log(a.nested.x); // 2 — same nested objectFor deep copies, structuredClone handles cycles and most built-ins, but drops functions (they throw) and rejects some host objects — a deliberate correctness-over-convenience choice.
Exotic Objects and Property Ordering
Integer-like keys are ordered numerically first, then string keys in insertion order, then symbols. Code relying on object key order for logic is fragile; use Map when order matters deliberately.
Proxy objects (covered later) can intercept [[Get]]/[[Set]]; typed arrays are "exotic" with invariants normal objects don't have. When debugging "why did this property lookup behave strangely," check for a Proxy or an accessor on the chain first.
Common errors
- Spread copies silently sharing nested objects, then mutating through one alias.
- Assuming
Object.freezeis recursive. - Using
for...in, which walks the prototype chain and includes inherited enumerable properties — preferObject.keys/Object.entries.
for (const k in { a: 1 }) {} // includes inherited keys
Object.entries({ a: 1 }); // own enumerable string keys onlyexercise Q: Why does b.nested.x = 2 in the example above change a? A: Spread copies only the top level; b.nested references the same nested object as a.nested, so mutation through either alias is visible to both.
Spotted a mistake, or code that does not work?