How it works
The spec stores attributes as internal slots on each property: [[Value]] and [[Writable]] for data properties, [[Get]] and [[Set]] for accessors, plus [[Enumerable]] and [[Configurable]] for both. A Property Descriptor is the specification type that carries a subset of those fields, and Object.defineProperty turns your plain object into one with ToPropertyDescriptor.
ToPropertyDescriptor reads each field with HasProperty then Get, so inherited fields count. It throws a TypeError if get or set is neither callable nor undefined, or if a getter or setter is combined with value or writable. Then [[DefineOwnProperty]] runs ValidateAndApplyPropertyDescriptor against the property's current descriptor:
- If the property doesn't exist: reject when the object isn't extensible; otherwise create it, filling absent fields with the defaults (
undefined and false).
- If it exists and is configurable: apply any change, even turning a data property into an accessor.
- If it exists and is non-configurable: reject
configurable: true, any change of enumerable, and any switch between data and accessor. For an accessor, get and set must be the same functions. For a data property that isn't writable, reject writable: true and any value that isn't SameValue to the current one. A writable data property may still change its value and may go from writable: true to false, but never back.
Assignment uses the same data. OrdinarySetWithOwnDescriptor returns false when the found data property is non-writable, and the caller throws in strict code. It also looks at the prototype chain first, which is why an inherited read-only property blocks the write instead of being shadowed.
Edge cases
const o = {};
Object.defineProperty(o, 'v', { value: 1, writable: true });
o.v = 2; // allowed: still writable
Object.defineProperty(o, 'v', { writable: false }); // allowed: true → false
console.log(Object.getOwnPropertyDescriptor(o, 'v'));
// → { value: 2, writable: false, enumerable: false, configurable: false }
for (const attrs of [{ writable: true }, { value: 3 }, { value: 2 }]) {
try {
Object.defineProperty(o, 'v', attrs);
console.log('ok', Object.keys(attrs));
} catch {
console.log('rejected', Object.keys(attrs));
}
}
// → rejected [ 'writable' ]
// → rejected [ 'value' ]
// → ok [ 'value' ]
const inheritedFields = Object.create({ writable: true });
inheritedFields.value = 1;
const t = Object.defineProperty({}, 'p', inheritedFields);
console.log(Object.getOwnPropertyDescriptor(t, 'p'));
// → { value: 1, writable: true, enumerable: false, configurable: false }
Re-defining a non-configurable property with the same value is a successful no-op, which is why { value: 2 } passes. The last case shows writable: true coming from the descriptor object's prototype. Pass literal descriptors, not objects with a prototype chain.
Real-world scenario
Library code uses descriptors to make internals harder to touch. A module can expose a version constant that can't be overwritten or listed, and a class is already doing this for its methods:
const api = { fetchUser() {} };
Object.defineProperty(api, 'VERSION', { value: '2.1.0', enumerable: true });
console.log(Object.entries(api).length); // → 2
console.log(Object.getOwnPropertyDescriptor(api, 'VERSION').writable); // → false
class Account {
deposit() {}
}
console.log(Object.keys(Account.prototype)); // → []
The next two questions build on this: Object.freeze applies descriptor changes to every property, and getters/setters are the accessor half of the table above.
Trade-offs
Non-configurable properties are a one-way door: nothing, including your own code, can undo them, which makes tests and monkey-patching harder. Prefer writable: false with configurable: true when you want to forbid accidental writes but keep the option to redefine.