Property Descriptors: 4 Internal Slots and Object.defineProperty
Every JavaScript property carries more than just a value — it holds 4 control bits that determine whether the property can be written, enumerated, reconfigured, or deleted. Understanding these 4 internal slots is essential for reading Vue 2's reactivity system, understanding Object.freeze, and diagnosing mysterious silent assignment failures.
🔹 Level 1 · What You Need to Know
The 4 Property Descriptor Fields
Every JavaScript data property has 4 internal slots:
| Descriptor | Type | Default (direct assign) | Default (defineProperty) | Meaning |
|---|---|---|---|---|
value |
any | assigned value | undefined |
The property's current value |
writable |
boolean | true |
false |
Whether value can be changed |
enumerable |
boolean | true |
false |
Whether it appears in for...in / Object.keys |
configurable |
boolean | true |
false |
Whether the property can be deleted or redefined |
Key rule: Properties created with obj.x = 1 have all three control bits set to true. Properties created with Object.defineProperty default unspecified bits to false.
Inspecting Descriptors
const obj = { x: 1 };
Object.getOwnPropertyDescriptor(obj, 'x');
// { value: 1, writable: true, enumerable: true, configurable: true }
const obj2 = {};
Object.defineProperty(obj2, 'x', { value: 1 });
Object.getOwnPropertyDescriptor(obj2, 'x');
// { value: 1, writable: false, enumerable: false, configurable: false }
// Inspect all properties at once
Object.getOwnPropertyDescriptors(obj);
Object.defineProperty Use Cases
Use case 1: Vue 2 reactivity system
Vue 2 rewrites plain data properties as accessor properties using Object.defineProperty, intercepting reads and writes:
function defineReactive(obj, key, val) {
const dep = new Dep(); // dependency collector
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
dep.depend(); // collect the currently-computing watcher
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
dep.notify(); // notify all dependents
}
});
}
const data = { message: 'hello' };
defineReactive(data, 'message', data.message);
// reads and writes to data.message are now intercepted
Use case 2: Read-only constants
const config = {};
Object.defineProperty(config, 'API_URL', {
value: 'https://api.example.com',
writable: false,
enumerable: true,
configurable: false
});
config.API_URL = 'http://hack.com'; // silent fail (non-strict) or TypeError (strict)
console.log(config.API_URL); // 'https://api.example.com'
Use case 3: Hiding internal properties
class EventEmitter {
constructor() {
Object.defineProperty(this, '_listeners', {
value: new Map(),
writable: false,
enumerable: false, // hidden from for...in and JSON.stringify
configurable: false
});
}
}
const emitter = new EventEmitter();
console.log(Object.keys(emitter)); // [] — _listeners is invisible
JSON.stringify(emitter); // '{}'
Object.freeze vs Object.seal vs Object.preventExtensions
These three methods freeze objects to different degrees and are frequently confused:
| Method | No new properties | No delete | No modify value | Descriptor change |
|---|---|---|---|---|
Object.preventExtensions |
✓ | ✗ | ✗ | none |
Object.seal |
✓ | ✓ | ✗ | configurable → false |
Object.freeze |
✓ | ✓ | ✓ | configurable+writable → false |
// preventExtensions: no new properties, but existing ones are mutable
const a = { x: 1 };
Object.preventExtensions(a);
a.x = 99; // works
a.y = 2; // silent fail (TypeError in strict mode)
delete a.x; // works
// seal: no add, no delete, but values can change
const b = { x: 1 };
Object.seal(b);
b.x = 99; // works
b.y = 2; // fails
delete b.x; // fails
// freeze: fully immutable (but shallow!)
const c = { x: 1, nested: { y: 2 } };
Object.freeze(c);
c.x = 99; // fails
c.nested.y = 99; // works! — freeze doesn't recurse
Deep freeze requires manual recursion:
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(name => {
const value = obj[name];
if (typeof value === 'object' && value !== null) {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
5 Common Mistakes
Mistake 1: Thinking const prevents property modification
const obj = { x: 1 };
obj.x = 99; // perfectly legal — const only prevents re-binding the variable
obj = {}; // this throws: Assignment to constant variable
Mistake 2: The Object.defineProperty default-value trap
// Thinking you're only changing value, leaving others as-is
const obj = { x: 1 };
Object.defineProperty(obj, 'x', { value: 2 }); // writable becomes false!
obj.x = 3; // silent fail
// Correct approach:
Object.defineProperty(obj, 'x', { value: 2, writable: true, configurable: true, enumerable: true });
Mistake 3: Thinking frozen arrays are deeply immutable
const arr = [1, 2, 3];
Object.freeze(arr);
arr.push(4); // TypeError: Cannot add property 3, object is not extensible
arr[0] = 99; // TypeError: Cannot assign to read only property '0'
// But:
const arr2 = Object.freeze([{ val: 1 }]);
arr2[0].val = 99; // works! — the element object isn't frozen
Mistake 4: for...in enumerating unexpected prototype properties
function Animal(name) { this.name = name; }
Animal.prototype.breathe = function() {};
const dog = new Animal('Rex');
for (const key in dog) {
console.log(key); // 'name' and 'breathe' — prototype property is enumerated
}
// Correct approach: filter with hasOwnProperty, or use for...of + Object.keys
for (const key in dog) {
if (Object.prototype.hasOwnProperty.call(dog, key)) {
console.log(key); // only 'name'
}
}
Mistake 5: Thinking writable:false on a prototype prevents shadowing
// Covered in detail in Level 4, but the short answer:
// A writable:false property on a prototype prevents child objects from
// creating a shadowing own property — a silent fail in non-strict mode
🔸 Level 2 · How It Works Internally
The Actual Internal Structure
Each property is stored as a Property Descriptor Record in the engine, containing different field combinations. The critical distinction: data properties and accessor properties have completely different internal structures, and one property cannot be both.
Data Property Internal Slots:
┌─────────────────────────────────────────────────────────┐
│ Property Descriptor (Data) │
│ │
│ [[Value]] : any ECMAScript value │
│ [[Writable]] : Boolean (can [[Value]] be changed?) │
│ [[Enumerable]] : Boolean (shown in enumeration?) │
│ [[Configurable]] : Boolean (can it be reconfigured?) │
└─────────────────────────────────────────────────────────┘
Accessor Property Internal Slots:
┌─────────────────────────────────────────────────────────┐
│ Property Descriptor (Accessor) │
│ │
│ [[Get]] : Function | undefined (read trigger) │
│ [[Set]] : Function | undefined (write trigger)│
│ [[Enumerable]] : Boolean │
│ [[Configurable]] : Boolean │
└─────────────────────────────────────────────────────────┘
Data properties have [[Value]] and [[Writable]]; accessor properties have [[Get]] and [[Set]]. Both share [[Enumerable]] and [[Configurable]], but the first two fields are mutually exclusive.
Direct Assignment vs defineProperty Execution Paths
obj.x = 1 goes through the [[Set]] internal method; Object.defineProperty goes through [[DefineOwnProperty]]. These paths diverge significantly at edge cases.
Execution path for obj.x = 1:
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ obj.[[Set]]('x', 1, obj) │
│ │ │
│ ▼ │
│ 1. Does obj have own property 'x'? │
│ │ │
│ ├── Yes, data property │
│ │ ├── writable: false → strict: TypeError; non-strict: silent │
│ │ └── writable: true → call [[DefineOwnProperty]], update value │
│ │ │
│ ├── Yes, accessor property │
│ │ ├── [[Set]] is undefined → TypeError │
│ │ └── [[Set]] exists → call setter function │
│ │ │
│ └── No → walk prototype chain (see ch13) │
│ └── ultimately: create new data property on obj with │
│ writable/enumerable/configurable all true │
└──────────────────────────────────────────────────────────────────────────┘
Execution path for Object.defineProperty(obj, 'x', desc):
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ ValidateAndApplyPropertyDescriptor(obj, 'x', true, desc, current) │
│ │ │
│ ▼ │
│ 1. current is undefined (property doesn't exist) │
│ └── Is obj extensible? │
│ ├── No → TypeError │
│ └── Yes → create property, use desc values, defaults for rest │
│ │
│ 2. current exists │
│ └── Run validity checks (see Level 3) │
│ ├── Valid → update specified fields │
│ └── Invalid → TypeError │
└──────────────────────────────────────────────────────────────────────────┘
What's Permitted After configurable:false
This is the most common source of confusion. configurable: false is not "nothing can change":
Rules when configurable: false:
┌──────────────────────────────────────────────────────┬─────────┐
│ Operation │ Allowed │
├──────────────────────────────────────────────────────┼─────────┤
│ delete the property │ No │
│ convert data → accessor │ No │
│ convert accessor → data │ No │
│ change [[Enumerable]] │ No │
│ change [[Configurable]] false → true │ No │
│ change [[Value]] (when writable:true) │ Yes │
│ change [[Writable]] true → false │ Yes │
│ change [[Writable]] false → true │ No │
│ change [[Get]] or [[Set]] │ No │
└──────────────────────────────────────────────────────┴─────────┘
The design principle: tightening is always allowed, loosening is not. Changing writable from true to false is tightening (voluntarily surrendering write access); the reverse is loosening.
const obj = {};
Object.defineProperty(obj, 'x', {
value: 1,
writable: true,
configurable: false
});
// Allowed: writable true → false
Object.defineProperty(obj, 'x', { writable: false }); // OK
// Not allowed: writable false → true
Object.defineProperty(obj, 'x', { writable: true }); // TypeError
// Not allowed: change enumerable
Object.defineProperty(obj, 'x', { enumerable: true }); // TypeError
// When writable is still true, changing value is allowed
const obj2 = {};
Object.defineProperty(obj2, 'x', { value: 1, writable: true, configurable: false });
Object.defineProperty(obj2, 'x', { value: 2 }); // OK — value changes from 1 to 2
Accessor Properties in Full
const obj = {};
let _x = 0;
Object.defineProperty(obj, 'x', {
get() {
console.log('reading x');
return _x;
},
set(val) {
console.log(`writing x: ${val}`);
_x = val;
},
enumerable: true,
configurable: true
});
// Shorthand syntax (equivalent):
const obj2 = {
get x() { return _x; },
set x(val) { _x = val; }
};
Common accessor patterns — computed values, lazy initialization, validation:
class Temperature {
#celsius = 0;
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(f) {
if (typeof f !== 'number') throw new TypeError('Temperature must be a number');
this.#celsius = (f - 32) * 5 / 9;
}
get celsius() { return this.#celsius; }
set celsius(c) {
if (c < -273.15) throw new RangeError('Below absolute zero');
this.#celsius = c;
}
}
const t = new Temperature();
t.celsius = 100;
console.log(t.fahrenheit); // 212
t.fahrenheit = 32;
console.log(t.celsius); // 0
🔺 Level 3 · How the Spec Defines It
Spec §6.2.6: Property Descriptor Type
The ECMAScript specification defines the Property Descriptor type in §6.2.6 as a spec-level Record type — not a JavaScript object, but an abstract concept internal to the engine.
Spec text (§6.2.6.1 IsAccessorDescriptor):
The abstract operation IsAccessorDescriptor takes argument Desc (a Property Descriptor or undefined) and returns a Boolean. It performs the following steps when called:
- If Desc is undefined, return false.
- If Desc has a [[Get]] field, return true.
- If Desc has a [[Set]] field, return true.
- Return false.
Similarly, IsDataDescriptor checks for [[Value]] or [[Writable]] fields, and IsGenericDescriptor handles descriptors with only enumerable/configurable.
The ValidateAndApplyPropertyDescriptor Algorithm
ValidateAndApplyPropertyDescriptor (§10.1.6.3) is the core of the property descriptor system. Here is the spec algorithm with annotated steps:
Inputs: O (object or undefined), P (property key), extensible (boolean), Desc (new descriptor), current (existing descriptor or undefined)
Step 1-2: current is undefined (property doesn't exist)
extensible is false → return false (cannot add properties)
Otherwise:
If IsGenericDescriptor(Desc) or IsDataDescriptor(Desc):
→ Create data property. Fields from Desc; unspecified fields default to false/undefined
Otherwise (Desc is accessor descriptor):
→ Create accessor property. Fields from Desc; unspecified default to false/undefined
→ return true
Step 3: If all Desc fields match current → return true (no change)
Step 4: current.[[Configurable]] is false:
4a. Desc.[[Configurable]] is true → return false
4b. Desc.[[Enumerable]] present and ≠ current.[[Enumerable]] → return false
Step 5: IsGenericDescriptor(Desc) → jump to Step 8
Step 6: IsDataDescriptor(current) ≠ IsDataDescriptor(Desc) (type conversion)
current.[[Configurable]] is false → return false
Otherwise: convert property type, preserve configurable/enumerable
Step 7: Both are data properties:
current.[[Configurable]] is false AND current.[[Writable]] is false:
7a. Desc.[[Writable]] is true → return false
7b. Desc.[[Value]] present AND SameValue(Desc.[[Value]], current.[[Value]]) is false → return false
Step 8: Both are accessor properties:
current.[[Configurable]] is false:
Desc.[[Set]] present and ≠ current.[[Set]] → return false
Desc.[[Get]] present and ≠ current.[[Get]] → return false
Step 9: O is not undefined (real object, not just validation)
→ Apply all present Desc fields to the property
Step 10: return true
Important note: Step 7b uses SameValue (equivalent to Object.is), not ===. This means NaN equals NaN, but +0 does not equal -0.
The Object.defineProperty Spec Implementation
Spec §20.1.2.4 Object.defineProperty(O, P, Attributes):
1. If O is not an object → throw TypeError
2. key ← ToPropertyKey(P)
3. desc ← ToPropertyDescriptor(Attributes)
(converts a plain JS object to an internal Property Descriptor Record)
4. Call O.[[DefineOwnProperty]](key, desc)
5. If result is false → throw TypeError
6. Return O
ToPropertyDescriptor(Obj) key steps (§6.2.6.6):
// Spec algorithm expressed as JS pseudocode
function ToPropertyDescriptor(Obj) {
if (typeof Obj !== 'object') throw new TypeError();
const desc = {};
if ('enumerable' in Obj) desc.enumerable = Boolean(Obj.enumerable);
if ('configurable' in Obj) desc.configurable = Boolean(Obj.configurable);
if ('value' in Obj) desc.value = Obj.value;
if ('writable' in Obj) desc.writable = Boolean(Obj.writable);
if ('get' in Obj) {
if (typeof Obj.get !== 'function' && Obj.get !== undefined)
throw new TypeError();
desc.get = Obj.get;
}
if ('set' in Obj) {
if (typeof Obj.set !== 'function' && Obj.set !== undefined)
throw new TypeError();
desc.set = Obj.set;
}
// Invariant check
if ('get' in desc || 'set' in desc) {
if ('value' in desc || 'writable' in desc)
throw new TypeError('accessor and data descriptor are mutually exclusive');
}
return desc;
}
💎 Level 4 · Edge Cases and Traps
Trap 1: Why Array.push Throws TypeError After Object.freeze
This is the most common "I thought I understood freeze" scenario:
const arr = [1, 2, 3];
Object.freeze(arr);
arr.push(4); // TypeError: Cannot add property 3, object is not extensible
Full derivation:
The spec implementation of Array.prototype.push (§23.1.3.20) is equivalent to:
Array.prototype.push = function(...items) {
let len = this.length; // read length
for (const item of items) {
this[len] = item; // 1. set index property
len++;
}
this.length = len; // 2. update length
return len;
};
What Object.freeze does to the array:
Object.freeze(arr);
// Equivalent to:
Object.preventExtensions(arr); // cannot add new properties (index 3 doesn't exist)
// For each element:
Object.defineProperty(arr, '0', { writable: false, configurable: false });
Object.defineProperty(arr, '1', { writable: false, configurable: false });
Object.defineProperty(arr, '2', { writable: false, configurable: false });
Object.defineProperty(arr, 'length', { writable: false }); // length is non-writable too!
During the push:
this[3] = 4: property3doesn't exist, butpreventExtensionsblocks adding new properties → TypeError- Even if step 1 were bypassed,
this.length = 4: length iswritable: false→ TypeError
// Verify the length descriptor:
Object.getOwnPropertyDescriptor(arr, 'length');
// { value: 3, writable: false, enumerable: false, configurable: false }
Real-world bug: A Redux store using Object.freeze to protect state — developer uses state.list.push(item) in a reducer instead of [...state.list, item]. In strict-mode tests the TypeError is caught; in production (non-strict or different freeze strategy), the push silently does nothing and the UI fails to update.
Trap 2: The One-Way Door of writable After configurable:false
const obj = {};
Object.defineProperty(obj, 'x', {
value: 42,
writable: true,
configurable: false,
enumerable: true
});
// Step 1: writable true → false (allowed — tightening)
Object.defineProperty(obj, 'x', { writable: false });
// Descriptor: { value: 42, writable: false, configurable: false, enumerable: true }
// Step 2: writable false → true (not allowed)
Object.defineProperty(obj, 'x', { writable: true });
// TypeError: Cannot redefine property: x
// Can we change value now that writable is false?
Object.defineProperty(obj, 'x', { value: 43 });
// TypeError — writable is false, value cannot change
Spec rationale: The design principle is monotonically decreasing capability. Once you relinquish a capability, you cannot reclaim it. configurable: false means you've surrendered reconfiguration rights; writable: true → false further surrenders write rights. This follows the principle of least authority.
Trap 3: Accessor and Data Properties Are Mutually Exclusive
// This throws TypeError
Object.defineProperty({}, 'x', {
value: 1,
get() { return 1; }
});
// TypeError: Invalid property descriptor.
// Cannot both specify accessors and a value or writable attribute
// Also illegal:
Object.defineProperty({}, 'x', {
writable: true,
set(v) {}
});
// TypeError
Why does the spec mandate this? Data properties store values directly; accessor properties manage values indirectly through functions. Specifying both is ambiguous — on assignment, should the setter be called, or should [[Value]] be updated directly?
// Correct: explicitly choose the property type
// Data property:
Object.defineProperty(obj, 'x', { value: 1, writable: true });
// Accessor property:
Object.defineProperty(obj, 'x', {
get() { return this._x; },
set(v) { this._x = v; }
});
// Converting data → accessor is possible if configurable: true
const obj = {};
Object.defineProperty(obj, 'x', { value: 1, writable: true, configurable: true });
Object.defineProperty(obj, 'x', { get() { return 42; } }); // conversion succeeds
Trap 4: Prototype writable:false Blocks Child Property Creation
This is the most insidious trap, caught even by experienced developers:
// Define a non-writable property on the prototype
const proto = {};
Object.defineProperty(proto, 'x', {
value: 1,
writable: false,
configurable: true,
enumerable: true
});
const child = Object.create(proto);
// Non-strict mode:
child.x = 99; // silent fail!
console.log(child.x); // 1 — reading the prototype value
console.log(child.hasOwnProperty('x')); // false — no own property created
// Strict mode:
'use strict';
child.x = 99; // TypeError: Cannot assign to read only property 'x' of object '#<Object>'
The [[Set]] algorithm step by step:
When child.x = 99 executes:
- Does
childhave an own propertyx? → No - Get
child.[[Prototype]]=proto; doesprotohavex? → Yes proto.xis a data property withwritable: false- Regardless of receiver (child), return false — do not create own property
- Non-strict: silent fail; strict: throw TypeError
Key insight: This has nothing to do with child's own writability — child doesn't even have this property. The behavior is determined by the writable value of the property found on the prototype chain.
// Contrast: when prototype has writable:true
const proto2 = {};
Object.defineProperty(proto2, 'y', { value: 1, writable: true, configurable: true });
const child2 = Object.create(proto2);
child2.y = 99; // success! creates own property on child2, shadowing prototype
console.log(child2.y); // 99 — own property
console.log(proto2.y); // 1 — prototype unchanged
console.log(child2.hasOwnProperty('y')); // true
Trap 5: Why Vue 2 Cannot Detect Array Index Assignment
This is Vue 2's most famous limitation, rooted in how Object.defineProperty works:
// What Vue 2 does when initializing data:
function observe(data) {
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key]);
});
}
// For an array:
const vm = new Vue({
data: { list: [1, 2, 3] }
});
// Vue 2 defines a getter/setter for `list` itself (as a property)
// But list[0], list[1], list[2] are NOT defineProperty'd
The root cause:
// Vue 2 CAN detect:
vm.list = [4, 5, 6]; // triggers list's setter, Vue re-observes the new array
// Vue 2 CANNOT detect:
vm.list[0] = 99; // directly mutates array index — no setter fires
vm.list.length = 1; // same — no detection
// Why not defineProperty every index?
// Evan You's (Vue author) reasoning:
// 1. Performance: arrays can have thousands of elements
// 2. Cannot detect future elements (new indices after push)
// 3. Even with it, only "replace" is detectable, not "add"
Vue 2's workaround: override the 7 mutation methods on array instances:
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value(...args) {
const result = original.apply(this, args);
const ob = this.__ob__; // observer instance
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args; break;
case 'splice':
inserted = args.slice(2); break;
}
if (inserted) ob.observeArray(inserted); // make new elements reactive
ob.dep.notify(); // notify updates
return result;
},
enumerable: false,
writable: true,
configurable: true
});
});
Vue 3's solution: replace Object.defineProperty with Proxy (see ch15). Proxy's set trap intercepts all property assignments — any index, length, everything.
// Vue 3 — the limitation simply doesn't exist:
const state = reactive({ list: [1, 2, 3] });
state.list[0] = 99; // triggers update!
state.list.length = 1; // triggers update!
state.list.push(4); // triggers update!
Chapter Summary
-
Every property has 4 internal slots: data properties use value/writable/enumerable/configurable; accessor properties replace the first two with get/set. Properties created with direct assignment have all three control bits as
true;definePropertydefaults unspecified ones tofalse. -
configurable:false is a one-way door: once set, it cannot be undone.
writablecan only go fromtruetofalse, never back.enumerableand the data/accessor type cannot be changed. -
Object.freeze is shallow: it has no effect on nested objects. Pushing to a frozen array throws TypeError because
lengthalso becomeswritable: false. -
A prototype's writable:false blocks own property creation on children: the most insidious source of silent failures in non-strict mode.
-
Vue 2's array limitation stems from Object.defineProperty itself: it cannot detect array index assignment. Vue 2 works around it by overriding mutation methods; Vue 3 solves it at the root with Proxy.