JavaScript Object.defineProperty: Property Descriptors

When you create an object property using dot notation or bracket syntax, JavaScript applies default settings behind the scenes. Property descriptors expose these settings, giving you explicit control...

Key Insights

  • Property descriptors give you fine-grained control over object properties beyond simple assignment, allowing you to make properties read-only, non-enumerable, or non-configurable.
  • Data descriptors (value, writable, enumerable, configurable) and accessor descriptors (get, set) are mutually exclusive—you cannot mix them for the same property.
  • Use Object.defineProperty() for framework-like features such as data validation, computed properties, and immutable configurations, but stick with regular assignment for everyday object creation.

Introduction to Property Descriptors

When you create an object property using dot notation or bracket syntax, JavaScript applies default settings behind the scenes. Property descriptors expose these settings, giving you explicit control over how properties behave.

Every property in JavaScript is defined by a descriptor object containing metadata about that property. There are two types: data descriptors and accessor descriptors. Data descriptors hold a value directly, while accessor descriptors use getter and setter functions to compute or control access to a value.

Here’s the fundamental difference:

// Regular assignment - uses default descriptor settings
const user = {};
user.name = 'Alice';

// Explicit descriptor - full control over property behavior
Object.defineProperty(user, 'id', {
  value: 12345,
  writable: false,
  enumerable: true,
  configurable: false
});

user.name = 'Bob';  // Works fine
user.id = 99999;    // Silently fails in non-strict mode, throws in strict mode

Understanding property descriptors is essential when building libraries, implementing data validation, or creating objects with specific behavioral constraints.

Data Descriptor Attributes

Data descriptors have four attributes that control property behavior:

value: The actual value associated with the property. Defaults to undefined.

writable: Whether the value can be changed with an assignment operator. Defaults to false when using Object.defineProperty(), but true for regular assignments.

enumerable: Whether the property appears in for...in loops and Object.keys(). Defaults to false with Object.defineProperty(), true for regular assignments.

configurable: Whether the property descriptor can be changed or the property deleted. Defaults to false with Object.defineProperty(), true for regular assignments.

Read-Only Properties

Creating immutable properties is straightforward with writable: false:

const config = {};

Object.defineProperty(config, 'API_KEY', {
  value: 'sk_live_abc123',
  writable: false,
  enumerable: true,
  configurable: false
});

config.API_KEY = 'sk_live_xyz789';  // Fails silently or throws in strict mode
console.log(config.API_KEY);        // 'sk_live_abc123'

Non-Enumerable Properties

Non-enumerable properties don’t appear in iterations, useful for metadata or internal properties:

const person = { name: 'Alice', age: 30 };

Object.defineProperty(person, '_id', {
  value: 'internal-12345',
  enumerable: false
});

console.log(Object.keys(person));     // ['name', 'age']
for (let key in person) {
  console.log(key);                   // 'name', 'age' (no '_id')
}
console.log(person._id);              // 'internal-12345' - still accessible

Non-Configurable Properties

Once a property is non-configurable, you cannot delete it or change most of its descriptor attributes:

const app = {};

Object.defineProperty(app, 'version', {
  value: '1.0.0',
  writable: true,
  enumerable: true,
  configurable: false
});

delete app.version;  // Fails
app.version = '2.0.0';  // Works - writable is still true

// Attempting to reconfigure throws an error
Object.defineProperty(app, 'version', {
  enumerable: false  // TypeError: Cannot redefine property
});

Accessor Descriptors (Getters and Setters)

Accessor descriptors replace value and writable with get and set functions. This enables computed properties, validation, and side effects when accessing or modifying properties.

Computed Properties

A classic use case is deriving values from other properties:

const person = {
  firstName: 'Alice',
  lastName: 'Johnson'
};

Object.defineProperty(person, 'fullName', {
  get() {
    return `${this.firstName} ${this.lastName}`;
  },
  set(value) {
    const parts = value.split(' ');
    this.firstName = parts[0];
    this.lastName = parts[1];
  },
  enumerable: true,
  configurable: true
});

console.log(person.fullName);  // 'Alice Johnson'
person.fullName = 'Bob Smith';
console.log(person.firstName); // 'Bob'
console.log(person.lastName);  // 'Smith'

Validation Logic

Setters can enforce constraints on property values:

const account = { _balance: 0 };

Object.defineProperty(account, 'balance', {
  get() {
    return this._balance;
  },
  set(value) {
    if (typeof value !== 'number' || value < 0) {
      throw new TypeError('Balance must be a non-negative number');
    }
    this._balance = value;
  },
  enumerable: true
});

account.balance = 100;   // Works
account.balance = -50;   // Throws TypeError

Private Variables with Closures

Combine closures with getters/setters to create truly private state:

function createCounter() {
  let count = 0;  // Truly private
  
  const counter = {};
  
  Object.defineProperty(counter, 'value', {
    get() { return count; },
    set(val) {
      if (val >= 0) count = val;
    }
  });
  
  Object.defineProperty(counter, 'increment', {
    value() { count++; }
  });
  
  return counter;
}

const counter = createCounter();
console.log(counter.value);  // 0
counter.increment();
console.log(counter.value);  // 1
counter.value = -10;         // Ignored by setter
console.log(counter.value);  // Still 1

Object.defineProperties and Object.getOwnPropertyDescriptor

For defining multiple properties at once, use Object.defineProperties():

const product = {};

Object.defineProperties(product, {
  id: {
    value: 'PROD-001',
    writable: false,
    enumerable: true
  },
  name: {
    value: 'Widget',
    writable: true,
    enumerable: true
  },
  price: {
    get() { return this._price || 0; },
    set(val) {
      if (val < 0) throw new Error('Price cannot be negative');
      this._price = val;
    },
    enumerable: true
  }
});

To inspect existing property descriptors, use Object.getOwnPropertyDescriptor():

const descriptor = Object.getOwnPropertyDescriptor(product, 'id');
console.log(descriptor);
// {
//   value: 'PROD-001',
//   writable: false,
//   enumerable: true,
//   configurable: false
// }

const priceDescriptor = Object.getOwnPropertyDescriptor(product, 'price');
console.log(priceDescriptor.get);  // [Function: get]
console.log(priceDescriptor.set);  // [Function: set]

Practical Use Cases

Immutable Configuration Objects

Create configuration objects where certain values cannot be changed after initialization:

function createConfig(options) {
  const config = {};
  
  Object.defineProperties(config, {
    apiUrl: {
      value: options.apiUrl,
      writable: false,
      enumerable: true,
      configurable: false
    },
    timeout: {
      value: options.timeout || 5000,
      writable: false,
      enumerable: true,
      configurable: false
    },
    debug: {
      value: options.debug || false,
      writable: true,  // Allow toggling debug mode
      enumerable: true,
      configurable: false
    }
  });
  
  return config;
}

const config = createConfig({ apiUrl: 'https://api.example.com' });
config.apiUrl = 'https://malicious.com';  // Fails
config.debug = true;  // Works

Observable Pattern for Property Changes

Track when properties change, useful for reactive frameworks:

function observable(obj) {
  const listeners = {};
  
  Object.keys(obj).forEach(key => {
    let value = obj[key];
    
    Object.defineProperty(obj, key, {
      get() { return value; },
      set(newValue) {
        const oldValue = value;
        value = newValue;
        if (listeners[key]) {
          listeners[key].forEach(fn => fn(newValue, oldValue));
        }
      },
      enumerable: true
    });
  });
  
  obj.on = function(prop, callback) {
    if (!listeners[prop]) listeners[prop] = [];
    listeners[prop].push(callback);
  };
  
  return obj;
}

const state = observable({ count: 0 });
state.on('count', (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`);
});

state.count = 5;  // Logs: "Count changed from 0 to 5"

Sealed API Objects

Create objects with a fixed interface that prevents extension:

function createAPI(token) {
  const api = {};
  
  Object.defineProperties(api, {
    token: {
      value: token,
      writable: false,
      enumerable: false,
      configurable: false
    },
    request: {
      value: function(endpoint) {
        return fetch(endpoint, {
          headers: { 'Authorization': `Bearer ${this.token}` }
        });
      },
      writable: false,
      enumerable: true,
      configurable: false
    }
  });
  
  Object.preventExtensions(api);
  return api;
}

Common Pitfalls and Best Practices

Descriptor Conflicts

You cannot mix data and accessor descriptors. This throws an error:

// TypeError: Invalid property descriptor
Object.defineProperty(obj, 'prop', {
  value: 42,
  get() { return 42; }
});

Choose one or the other based on your needs.

Performance Considerations

Accessor properties with complex getters/setters are slower than direct property access. For performance-critical code with frequent property access, prefer simple data properties:

// Slower - getter called every time
Object.defineProperty(obj, 'computed', {
  get() { return this.a + this.b; }
});

// Faster - value stored directly
obj.computed = obj.a + obj.b;

Descriptor Inheritance

Property descriptors are not inherited through the prototype chain. Each object defines its own descriptors:

const parent = {};
Object.defineProperty(parent, 'inherited', {
  value: 'parent value',
  writable: false
});

const child = Object.create(parent);
console.log(child.inherited);  // 'parent value'

// This creates a new own property on child, not modifying parent
child.inherited = 'child value';  // Fails in strict mode

Use Object.defineProperty() when you need precise control over property behavior, but for everyday object creation, regular assignment is clearer and more performant. Reserve property descriptors for library code, framework internals, and situations where you need guarantees about property behavior.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.