Ch. 1 · JavaScript

JavaScript Property Descriptors: writable, enumerable, configurable

How Object.defineProperty and property descriptors control writes, enumeration and deletion, how getters and setters fit in, and their traps.

~6 min readintermediateupdated Oct 6, 2026

“How would you add a property to an object that cannot be changed, does not show up in Object.keys, and cannot be deleted?” Or: “What is the difference between obj.x = 1 and Object.defineProperty(obj, 'x', { value: 1 })?” Questions about property descriptors appear in intermediate and senior JavaScript interviews because descriptors are the machinery behind Object.freeze, class getters, non-enumerable built-in methods, and much of how frameworks implement reactivity. Candidates who understand them can explain why for...in does not list toString, and why a “read-only” property silently ignored a write.

The trap interviewers set is the defaults: properties created with defineProperty behave very differently from properties created by assignment.

Before you start

You should know object literals, Object.keys, for...in, and what strict mode does to failed assignments (they throw instead of being ignored). Basic familiarity with getters and setters in classes or object literals helps.

The short answer

Every own property has a descriptor. A data descriptor has value and writable; an accessor descriptor has get and set; both kinds have enumerable (shown by Object.keys, for...in, spread and JSON) and configurable (can be deleted or redefined). Assignment creates properties with every flag true, but Object.defineProperty defaults any flag you omit to false. Writes to non-writable properties fail silently in sloppy mode and throw a TypeError in strict mode.

How it works

Use Object.getOwnPropertyDescriptor to inspect one property, or Object.getOwnPropertyDescriptors for all of them.

const user = { name: 'Ada' };
Object.defineProperty(user, 'id', { value: 42 });

console.log(Object.getOwnPropertyDescriptor(user, 'name'));
// { value: 'Ada', writable: true, enumerable: true, configurable: true }
console.log(Object.getOwnPropertyDescriptor(user, 'id'));
// { value: 42, writable: false, enumerable: false, configurable: false }

console.log(Object.keys(user)); // [ 'name' ]
console.log(JSON.stringify(user)); // {"name":"Ada"}
console.log(user.id); // 42
JavaScript

The id property exists and can be read, but it is hidden from enumeration and serialization, and it cannot be changed or removed. This is exactly how built-in methods such as Array.prototype.map are defined, which is why they do not appear when you loop over an array with for...in.

Accessor properties replace value and writable with functions. A getter runs on every read; a setter receives the assigned value.

const temperature = { celsius: 20 };
Object.defineProperty(temperature, 'fahrenheit', {
  get() { return this.celsius * 9 / 5 + 32; },
  set(f) { this.celsius = (f - 32) * 5 / 9; },
  enumerable: true,
});
console.log(temperature.fahrenheit); // 68
temperature.fahrenheit = 212;
console.log(temperature.celsius); // 100
JavaScript

A descriptor cannot mix the two kinds: passing both value and get throws a TypeError.

Step-by-step walkthrough

Step 1: Make a property read-only

Set writable: false. In strict mode, writes throw, which makes bugs visible.

'use strict';
const config = {};
Object.defineProperty(config, 'apiVersion', { value: 'v2', enumerable: true });
try {
  config.apiVersion = 'v3';
} catch (e) {
  console.log(e.message); // Cannot assign to read only property 'apiVersion' of object '#<Object>'
}
console.log(config.apiVersion); // v2
JavaScript

Step 2: Understand what configurable locks

configurable: false prevents deleting the property, switching between data and accessor, and changing enumerable or configurable. It is permanent. One change is still allowed: a writable data property can be made non-writable (and its value can still be changed while it is writable).

const o = {};
Object.defineProperty(o, 'x', { value: 1, writable: true, configurable: false });
o.x = 2;
Object.defineProperty(o, 'x', { writable: false });
console.log(Object.getOwnPropertyDescriptor(o, 'x'));
// { value: 2, writable: false, enumerable: false, configurable: false }
try {
  Object.defineProperty(o, 'x', { writable: true });
} catch (e) {
  console.log(e.constructor.name); // TypeError
}
console.log(Reflect.deleteProperty(o, 'x')); // false
JavaScript

Reflect.deleteProperty reports failure by returning false. A plain delete o.x also returns false in sloppy mode, but in strict mode (including every ES module) it throws a TypeError.

Step 3: Add validation with a setter

Setters let you enforce invariants on assignment. Store the real value somewhere private, such as a closure or a #private field, so callers cannot bypass the check.

function createAccount() {
  let balance = 0;
  const account = {};
  Object.defineProperty(account, 'balance', {
    get: () => balance,
    set: (v) => {
      if (!Number.isFinite(v) || v < 0) throw new RangeError(`invalid balance: ${v}`);
      balance = v;
    },
    enumerable: true,
  });
  return account;
}
const acct = createAccount();
acct.balance = 100;
try {
  acct.balance = -5;
} catch (e) {
  console.log(e.message); // invalid balance: -5
}
console.log(acct.balance); // 100
JavaScript

Step 4: Copy descriptors, not just values

Spread and Object.assign read values through getters and create plain writable data properties on the target. Getters are flattened into a snapshot. To clone an object with its accessors and flags intact, copy the descriptors.

const source = {
  first: 'Ada',
  last: 'Lovelace',
  get full() { return `${this.first} ${this.last}`; },
};
const spreadCopy = { ...source };
spreadCopy.first = 'Grace';
console.log(spreadCopy.full); // Ada Lovelace

const trueCopy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(source));
trueCopy.first = 'Grace';
console.log(trueCopy.full); // Grace Lovelace
JavaScript

Worked scenario

A library exposed a VERSION constant on its exported object and wanted it read-only. A developer used defineProperty but copied the object with spread before export to “add a few helpers.” The resulting object’s VERSION was writable again, and a plugin overwrote it, breaking compatibility checks.

'use strict';
const core = {};
Object.defineProperty(core, 'VERSION', { value: '3.1.0', enumerable: true });
const exportedBroken = { ...core, helper() {} };
exportedBroken.VERSION = '9.9.9';
console.log(exportedBroken.VERSION); // 9.9.9
JavaScript

Spread copied the value into a brand-new property with default assignment flags. The fix defines the protected property on the final exported object and verifies it at load time.

'use strict';
const exported = { helper() {} };
Object.defineProperty(exported, 'VERSION', { value: '3.1.0', enumerable: true });
try {
  exported.VERSION = '9.9.9';
} catch (e) {
  console.log('blocked'); // blocked
}
console.log(Object.getOwnPropertyDescriptor(exported, 'VERSION').writable); // false
JavaScript

Common mistake

  • “defineProperty creates a normal property.” Omitted flags default to false, so the property is read-only, hidden and permanent unless you say otherwise.
  • “Non-writable means nobody can change the value.” A non-writable but configurable property can be redefined with a new value through defineProperty. Only writable: false together with configurable: false truly locks it.
  • “Writes to read-only properties always throw.” Only in strict mode. In sloppy mode they vanish silently.
  • Infinite recursion in a setter. set name(v) { this.name = v; } calls itself forever. Store the value under a different key or in a private field.
  • Expecting spread to keep getters. Spread and Object.assign invoke getters and copy the results.
  • Forgetting inherited setters. Assigning to a property that an object inherits as non-writable or as a setter goes through the prototype’s rules, not a new own property.

Verify the behavior

import test from 'node:test';
import assert from 'node:assert/strict';

test('defineProperty defaults flags to false', () => {
  const o = Object.defineProperty({}, 'k', { value: 1 });
  const d = Object.getOwnPropertyDescriptor(o, 'k');
  assert.deepEqual([d.writable, d.enumerable, d.configurable], [false, false, false]);
});

test('strict-mode write to read-only property throws', () => {
  const o = Object.defineProperty({}, 'k', { value: 1 });
  assert.throws(() => { o.k = 2; }, TypeError);
});

test('descriptor copy preserves getters', () => {
  const src = { a: 1, get double() { return this.a * 2; } };
  const copy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(src));
  copy.a = 5;
  assert.equal(copy.double, 10);
});
JavaScript

Save as descriptors.test.mjs and run node --test descriptors.test.mjs; ES modules are strict, so the second test sees the TypeError.

Follow-up questions

How do class getters relate to descriptors? A get in a class body creates a non-enumerable accessor on the prototype. Class methods are non-enumerable too, which is why they do not appear in Object.keys(instance).

How are freeze and seal implemented? seal sets every own property to configurable: false and prevents extensions; freeze also sets data properties to writable: false.

What does Object.defineProperties do? It defines several properties at once from an object of descriptors, which pairs with getOwnPropertyDescriptors for faithful cloning.

Did Vue use descriptors for reactivity? Vue 2 converted data properties into getters and setters with defineProperty to track reads and writes. Vue 3 switched to Proxy, which can also observe added and deleted properties.

Interview exercise

Write defineConstant(obj, key, value) so that the property is visible in Object.keys, cannot be reassigned, deleted or redefined, and explain what happens in sloppy mode when someone tries to change it.

Answer and reasoning

function defineConstant(obj, key, value) {
  return Object.defineProperty(obj, key, {
    value,
    enumerable: true,
    writable: false,
    configurable: false,
  });
}
const settings = defineConstant({}, 'MAX_USERS', 50);
console.log(Object.keys(settings)); // [ 'MAX_USERS' ]
try {
  Object.defineProperty(settings, 'MAX_USERS', { value: 99 });
} catch (e) {
  console.log(e.constructor.name); // TypeError
}
console.log(settings.MAX_USERS); // 50
JavaScript

enumerable: true keeps it visible to Object.keys, spread and JSON. writable: false blocks assignment, and configurable: false blocks deletion and redefinition with a different value, which writable: false alone would not prevent. Being explicit about all four flags documents intent even though false is the default. In sloppy mode, settings.MAX_USERS = 99 is silently ignored and delete settings.MAX_USERS returns false; in strict mode both throw. A defineProperty call with a different value throws in both modes. If the value is an object, mention that its contents stay mutable unless frozen.

Continue learning

Explore more JavaScript interview questions and try the JavaScript MCQs. Related notes: Object.freeze and its limits, property enumeration order and Proxy validation traps. Official reference: Object.defineProperty on MDN.

More in JavaScript

esc