Object.defineProperty()

ES5+

Defines a new property on an object — or reconfigures an existing one — using a descriptor that controls its value, writability, enumerability, configurability, or getter/setter pair. It is the low-level primitive behind class accessors, non-enumerable methods, and most pre-Proxy reactivity systems.

Syntax

Object.defineProperty(obj, prop, descriptor)

Parameters

obj Object

The object on which to define the property

prop string

The name of the property to define

descriptor Object

The descriptor for the property being defined

Return Value

Object

The object that was passed to the function

Examples

JavaScript
const obj = {};
Object.defineProperty(obj, 'x', {
  value: 42,
  writable: false
});
console.log(obj.x);
obj.x = 100;
console.log(obj.x);
Output:
// 42 42

📌 When to Use

Use Object.defineProperty() when plain assignment cannot express what a property needs to be. That includes: computed accessor properties whose value derives from other fields at read time; validating or intercepting setters that reject bad writes or notify observers (the mechanism behind Vue 2 reactivity and countless data-binding layers); non-enumerable helpers that must not appear in Object.keys(), JSON.stringify(), or spread copies — metadata, framework hooks, and methods attached to data objects; read-only constants on shared objects via writable: false; and lazy initialization, where a getter computes an expensive value once and then redefines itself as a plain data property. Library authors use it to retrofit properties onto objects they do not construct, and polyfills rely on it to install standard methods non-enumerably so they behave like native ones. For your own classes, prefer class getter and setter syntax, which compiles to the same descriptors with far better readability. Assignment semantics differ subtly too: obj.x = 1 respects inherited setters and fails on inherited non-writable properties, while defineProperty always operates directly on the target object — occasionally exactly the escape hatch you need.

⚠️ Common Mistakes

Recreating the accessor-recursion bug - get value() { return this.value; } calls itself forever and overflows the stack. Accessors need a differently-named backing store (this._value or a closure variable); this is a mistake nearly everyone makes exactly once.

Expecting defined properties to survive spread and JSON - spread copies read a getter once and produce a plain data snapshot, drop non-enumerable properties entirely, and JSON.stringify() ignores non-enumerable properties too. Descriptor-level features do not travel with generic copying tools; migrate them with Object.getOwnPropertyDescriptors().

Forgetting default values - all boolean descriptors (writable, enumerable, configurable) default to false when using defineProperty.

Mixing value/writable with get/set - you cannot define both a value and accessors on the same property.

Not setting configurable: true when you might need to redefine the property later - once false, it cannot be changed.

✅ Best Practices

Implement lazy one-time computation by self-replacing: the getter computes the value, then calls Object.defineProperty(this, key, { value: result }) to overwrite itself, so every later read is a plain fast property access with no repeated work and no "did we compute it yet" flag.

When adding methods to shared or built-in prototypes (polyfills), always define them with enumerable: false so they do not suddenly appear in every consumer's for...in loops and object spreads - the single most common way naive prototype extensions break other code.

Use computed property getters for derived values: defineProperty(obj, "fullName", { get() { return this.first + " " + this.last; } })

Create non-enumerable methods to avoid them appearing in for...in loops or Object.keys().

Consider using ES6 class getters/setters for simpler cases - they are more readable than defineProperty.

⚡ Performance Notes

Accessor properties cost a function call per read or write, which engines can inline at monomorphic call sites but which never beats a raw data property; heavy per-frame code should read through accessors once into locals rather than repeatedly. Calling Object.defineProperty() on a live object also transitions its hidden class, and objects that undergo many one-off definitions can fall into slow dictionary mode — batch definitions with Object.defineProperties() or, better, define everything at creation via Object.create() with descriptors. Vue 2 famously paid a per-property defineProperty walk over every reactive object; Vue 3 moved to Proxy partly because one Proxy wraps a whole object in constant time instead of O(n) descriptor surgery — a useful data point when choosing between the two interception techniques. Non-enumerable properties impose no read penalty; they simply skip enumeration. Redefining hot properties repeatedly is the worst pattern: define once, mutate values thereafter.

🌍 Real World Example

Reactive Data Binding System

This miniature reactivity system reproduces the core of Vue 2: reactive() replaces every data property with a getter/setter pair around a hidden values store, logging reads, detecting real changes, and notifying subscribed watcher callbacks on writes. The $watch method itself is added with enumerable: false, so Object.keys(user) still reports only the data fields — infrastructure stays invisible to iteration and serialization. Studying and extending this pattern teaches exactly how data-binding frameworks observed state before Proxy-based designs took over the ecosystem.

function reactive(target) {
  const listeners = {};
  const values = { ...target };

  Object.keys(target).forEach(key => {
    listeners[key] = [];

    Object.defineProperty(target, key, {
      get() {
        console.log('Getting ' + key + ': ' + values[key]);
        return values[key];
      },
      set(newValue) {
        const oldValue = values[key];
        if (oldValue !== newValue) {
          values[key] = newValue;
          console.log('Setting ' + key + ': ' + oldValue + ' -> ' + newValue);
          listeners[key].forEach(fn => fn(newValue, oldValue));
        }
      },
      enumerable: true,
      configurable: true
    });
  });

  // Add watch method (non-enumerable)
  Object.defineProperty(target, '$watch', {
    value: function(key, callback) {
      if (listeners[key]) {
        listeners[key].push(callback);
      }
    },
    enumerable: false
  });

  return target;
}

// Usage
const user = reactive({
  name: 'John',
  age: 25
});

// Watch for changes
user.$watch('age', (newVal, oldVal) => {
  console.log('Age changed from ' + oldVal + ' to ' + newVal);
});

user.name; // "Getting name: John"
user.age = 26; // "Setting age: 25 -> 26", "Age changed from 25 to 26"

// $watch is not enumerable
console.log(Object.keys(user)); // ['name', 'age']

Related Methods