Objects and Properties
JavaScript objects are the language's central data structure: dynamic bags of key–value pairs. But beneath the simple obj.prop syntax sits a precise machinery of descriptors, accessors, extensibility flags, and prototype delegation. This chapter assumes you are already comfortable with values, types, functions, and closures, and have at least passing familiarity with the fact that a prototype chain exists (a later chapter treats it fully). Here we focus on the object itself: what a property actually is, and how to control one.
Property Kinds
An object has two kinds of property:
- Data properties store a value.
- Accessor properties are a getter and/or setter function pair.
Internally, every property is described by a property descriptor with these fields:
valueandwritable(data properties only)getandset(accessor properties only)enumerable— whether it shows up infor...inandObject.keysconfigurable— whether the descriptor can be changed or the property deleted
Inspect descriptors directly:
const obj = { a: 1 };
console.log(Object.getOwnPropertyDescriptor(obj, 'a'));
// { value: 1, writable: true, enumerable: true, configurable: true }
Object.defineProperty(obj, 'b', {
get() { return 42; },
enumerable: true,
});
console.log(obj.b); // 42The cost of a single misstep
defineProperty fails silently or throws depending on strict mode, and configurable: false is irreversible. Once you set it, the only change you can still make is writable: true → false. Nothing else — not re-defining, not deleting.
The Four Mutation Levels
Objects have their own meta-level controls, layered on top of per-property flags:
| Method | Prevents | Objects still extensible? | Existing props writable? |
|---|---|---|---|
| `Object.preventExtensions` | Adding new properties | No | Yes |
| `Object.seal` | Adding/removing; properties become non-configurable | No | Yes |
| `Object.freeze` | Everything: also makes properties non-writable | No | No |
freeze is shallow: nested objects remain mutable. Use a recursive freeze (or a library) if you need deep immutability, but be aware it also freezes functions and can be expensive on hot paths.
const state = Object.freeze({ user: { name: 'Ada' } });
state.user.name = 'Grace'; // silently ignored (or TypeError in strict mode)
console.log(state.user.name); // 'Grace' — the nested object was never frozenGetters, Setters, and Invariants
Accessor properties are declared in object literals or classes:
const account = {
#balance: 0, // private field (class fields syntax)
get balance() { return this.#balance; },
set balance(v) {
if (typeof v !== 'number' || v < 0) throw new RangeError('bad balance');
this.#balance = v;
},
};Key rule: accessors bypass writable. If you freeze an object containing accessors, the getter still runs — but any setter that mutates captured state via closure (rather than this) will happily "violate" the freeze. Freezing constrains the property table, not the functions themselves.
Also note: a setter with no getter makes the property read undefined — and in strict mode, reading it is fine, but the asymmetry often surprises readers of your code.
Symbols as Property Keys
Object keys are strings or symbols — unique, opaque values useful for "hidden-ish" properties:
const id = Symbol('id');
const obj = { [id]: 123, name: 'obj' };
console.log(Object.keys(obj)); // ['name'] — symbols excluded
console.log(Reflect.ownKeys(obj)); // ['name', Symbol(id)]Object.keys and JSON.stringify skip symbol keys; Object.assign and spread copy them. This asymmetry is a frequent source of serialization bugs: symbols survive cloning but not stringification.
Property Access: The Full Path
obj.prop is not a table lookup. It:
- Checks for an own accessor property → calls the getter.
- Checks for an own data property → returns the value.
- Walks the prototype chain, repeating 1–2.
- Returns
undefinedif nothing is found.
This is why reading a property is never guaranteed side-effect-free: a getter anywhere in the chain can execute arbitrary code. In performance-sensitive code, a monomorphic shape (consistent set of keys, no accessors) lets engines optimize access into inline cache loads; polymorphic access sites or getters deoptimize.
Copying and Merging Correctly
Spread and Object.assign perform shallow copies and copy via the [[Set]] operation — meaning they call setters on the target. To copy raw values instead, define with descriptors:
function copyDescriptors(src) {
return Object.defineProperties({}, Object.getOwnPropertyDescriptors(src));
}Note the difference: {...src} reads through getters and writes through setters; getOwnPropertyDescriptors copies the accessor functions themselves. Choose based on whether you want behavior or snapshot.
Common Errors and Gotchas
undefinedvs. missing:'x' in objis true even forobj.x = undefined. Useinfor presence,Object.hasOwn(obj, 'x')(ES2022) to exclude the prototype chain. Never trust truthiness —obj.xis falsy for0,'', andfalse.- Mutating shared references: assignment copies the reference, not the object.
{...a}nested two levels deep is not a safe copy. - Frozen ≠ deeply immutable (shown above), and frozen objects cannot gain hidden-class-fast new properties later — freezing too early can hurt performance.
- Ordering: string keys iterate in insertion order, except integer-like keys, which iterate in ascending numeric order first.
const o = { 3: 'c', 1: 'a', b: 'z' }iterates1, 3, b. deletedeoptimizes: it changes the object's hidden class. Prefer setting toundefined(and documenting) in hot code, or useMapwhen keys are dynamic.
Exercise
You freeze an object whose property is a getter reading from a closed-over variable. Can you still change the value the getter returns?
Show answer
Yes. Freezing prevents descriptor changes and property addition/removal, but the getter is just a function with closure state; mutating that captured variable is unaffected by the freeze.
Exercise
Why does { ...obj } differ from Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj)) when obj has a getter?
Show answer
Spread performs a [[Get]] on obj (running the getter) and a [[Set]] of the resulting value, producing a data property. The descriptors version copies the accessor itself, so the new object's getter runs against the new object when read.
Spotted a mistake, or code that does not work?