Chapter 12

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:

  1. If Desc is undefined, return false.
  2. If Desc has a [[Get]] field, return true.
  3. If Desc has a [[Set]] field, return true.
  4. 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:

// 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:

  1. Does child have an own property x? โ†’ No
  2. Get child.[[Prototype]] = proto; does proto have x? โ†’ Yes
  3. proto.x is a data property with writable: false
  4. Regardless of receiver (child), return false โ€” do not create own property
  5. 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

  1. 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; defineProperty defaults unspecified ones to false.

  2. configurable:false is a one-way door: once set, it cannot be undone. writable can only go from true to false, never back. enumerable and the data/accessor type cannot be changed.

  3. Object.freeze is shallow: it has no effect on nested objects. Pushing to a frozen array throws TypeError because length also becomes writable: false.

  4. A prototype's writable:false blocks own property creation on children: the most insidious source of silent failures in non-strict mode.

  5. 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.

Rate this chapter
4.6  / 5  (28 ratings)

๐Ÿ’ฌ Comments