JavaScript Property Descriptors and defineProperty: Writable, Enumerable, Configurable, and Getters/Setters
Every property on every JavaScript object has a property descriptor — a small set of flags that controls how that property behaves. When you write obj.x = 1, JavaScript creates a descriptor under the hood. When you Object.freeze(obj), it's flipping descriptor flags. When you define a class getter or set up reactive state, you're using the descriptor API directly.
Most code never touches Object.defineProperty — and that's fine. But knowing how descriptors work explains a lot of "why is this happening?" moments: why Object.freeze is shallow, why you can't always delete a property, what Object.assign actually copies, and how libraries like Vue and MobX make objects "reactive."
This lesson covers the descriptor model, the four flags, getters and setters, and the modern APIs (Object.freeze, Object.seal, Object.preventExtensions) that build on it.
What a descriptor is #
Every property on an object has one of two shapes:
Data descriptor #
{
value: 1,
writable: true,
enumerable: true,
configurable: true,
}
Accessor descriptor (getter/setter) #
{
get() { return this._x; },
set(v) { this._x = v; },
enumerable: true,
configurable: true,
}
An assignment like obj.x = 1 quietly creates a data descriptor with value: 1 and all three flags true.
Reading a descriptor #
const obj = { x: 1 };
Object.getOwnPropertyDescriptor(obj, 'x');
// { value: 1, writable: true, enumerable: true, configurable: true }
Object.getOwnPropertyDescriptors(obj);
// { x: { value: 1, writable: true, enumerable: true, configurable: true } }
For any property of any object, these calls show you exactly what JavaScript knows about it.
The four flags #
value #
The value of the property. For data descriptors only.
writable #
If false, assignments to the property silently fail in non-strict mode and throw in strict mode.
const obj = {};
Object.defineProperty(obj, 'x', { value: 1, writable: false });
obj.x = 99; // ignored (or throws in strict)
console.log(obj.x); // 1
This is how constants on objects work. Math.PI, for instance, has writable: false.
enumerable #
If false, the property is hidden from Object.keys, Object.entries, for...in, JSON serialization, and object spread.
const obj = {};
Object.defineProperty(obj, 'hidden', { value: 1, enumerable: false });
Object.defineProperty(obj, 'visible', { value: 2, enumerable: true });
Object.keys(obj); // ['visible']
JSON.stringify(obj); // '{"visible":2}'
const copy = { ...obj }; // { visible: 2 } — hidden was dropped
obj.hidden; // 1 — still accessible by name
This is how methods on class prototypes work — they're enumerable: false. That's why Object.keys(user) doesn't show methods on a class instance.
configurable #
If false, the property cannot be deleted, and its descriptor cannot be changed (except writable going from true to false, and changing value if writable is true).
Object.defineProperty(obj, 'x', { value: 1, configurable: false });
delete obj.x; // ignored (or throws in strict)
Object.defineProperty(obj, 'x', { value: 2 }); // OK if writable
Object.defineProperty(obj, 'x', { enumerable: false }); // throws — can't reconfigure
configurable: false is essentially "this property is permanent." Use sparingly — it's a one-way door.
Defining properties with Object.defineProperty #
Object.defineProperty(obj, 'name', {
value: 'Ada',
writable: true,
enumerable: true,
configurable: true,
});
If you omit flags, they default to false (not true!) when using defineProperty. This is the opposite of regular assignment:
const a = {};
a.x = 1;
Object.getOwnPropertyDescriptor(a, 'x');
// { value: 1, writable: true, enumerable: true, configurable: true }
const b = {};
Object.defineProperty(b, 'x', { value: 1 });
Object.getOwnPropertyDescriptor(b, 'x');
// { value: 1, writable: false, enumerable: false, configurable: false }
This is why class methods are hidden from Object.keys — they're defined via defineProperty (or equivalent internals) without enumerable: true.
For multiple properties at once, use Object.defineProperties:
Object.defineProperties(obj, {
firstName: { value: 'Ada', enumerable: true },
lastName: { value: 'Lovelace', enumerable: true },
});
Getters and setters #
Accessor descriptors define properties that compute their value when read or written.
const circle = {
radius: 5,
get diameter() { return this.radius * 2; },
set diameter(d) { this.radius = d / 2; },
};
circle.diameter; // 10 — computed
circle.diameter = 20; // calls the setter
circle.radius; // 10
Getters and setters look like properties at the call site — circle.diameter reads like a normal property, not a method call. But they're functions underneath.
Define them with object literal syntax (as above), class syntax, or Object.defineProperty:
Object.defineProperty(circle, 'diameter', {
get() { return this.radius * 2; },
set(d) { this.radius = d / 2; },
enumerable: true,
configurable: true,
});
Getters/setters can't have a value or writable — those are data-descriptor flags. The accessor descriptor takes get, set, enumerable, configurable.
When getters/setters earn their keep #
- Derived values — diameter from radius, full name from first + last, total from items.
- Logging or validation on write —
set age(v) { if (v < 0) throw ...; this._age = v; }. - Lazy computation — defining
getthat caches on first access. - Library APIs that look like properties — Vue/MobX reactivity, ORM relations.
Used sparingly, they make APIs feel natural. Overused, they hide costly computation behind innocent-looking property access.
Object.freeze, seal, and preventExtensions #
Three progressively-less-restrictive ways to lock down an object.
Object.freeze(obj) #
Makes every property writable: false and configurable: false, and prevents new properties from being added.
const obj = Object.freeze({ x: 1, y: 2 });
obj.x = 99; // ignored
obj.z = 3; // ignored
delete obj.x; // ignored
Object.isFrozen(obj); // true
Shallow only. Nested objects can still be mutated:
const obj = Object.freeze({ inner: { n: 0 } });
obj.inner.n = 99; // works — inner isn't frozen
obj.inner = {}; // ignored — top-level is frozen
For deep freezing, recurse:
function deepFreeze(obj) {
Object.values(obj).forEach(v => {
if (v && typeof v === 'object') deepFreeze(v);
});
return Object.freeze(obj);
}
Object.seal(obj) #
Makes every property configurable: false, prevents new properties — but leaves writable: true. You can change values, just not add/remove keys.
const obj = Object.seal({ x: 1 });
obj.x = 99; // OK
obj.y = 2; // ignored
delete obj.x; // ignored
Object.preventExtensions(obj) #
The weakest of the three. Prevents new properties; existing ones unchanged.
const obj = Object.preventExtensions({ x: 1 });
obj.x = 99; // OK
obj.y = 2; // ignored
delete obj.x; // OK
Rare. Useful for ensuring you don't typo a property name onto an object.
When this matters in practice #
Object.assign and spread copy ONLY enumerable own properties #
const src = {};
Object.defineProperty(src, 'a', { value: 1, enumerable: true });
Object.defineProperty(src, 'b', { value: 2, enumerable: false });
const copy = { ...src };
console.log(copy); // { a: 1 } — b was non-enumerable, dropped
This is why methods defined on prototypes don't show up in spread — they're non-enumerable.
Reactive frameworks use descriptors #
Vue 2 used Object.defineProperty to turn every property into a getter/setter that notified watchers. Vue 3 switched to Proxies (a related but different mechanism, covered in advanced material). MobX 6+ uses Proxies too. Knowing the descriptor model demystifies how "reactive" objects work.
JSON.stringify drops non-enumerable #
Object.defineProperty(obj, 'secret', { value: 'x', enumerable: false });
JSON.stringify(obj); // skips secret
Useful for hiding internal state from logs.
Symbol-keyed properties skip iteration too #
const hidden = Symbol('hidden');
const obj = { visible: 1, [hidden]: 2 };
Object.keys(obj); // ['visible']
Object.getOwnPropertySymbols(obj); // [Symbol(hidden)]
A common pattern for hiding internal slots while still allowing access by code that has the symbol.
A summary #
- Every property has a descriptor with four flags:
value,writable,enumerable,configurable. - Assignment creates them all
true.definePropertydefaults them allfalse. - Getters/setters use the accessor descriptor (
get/setinstead ofvalue/writable). Object.freeze= read-only top level.seal= no new/removed keys, values mutable.preventExtensions= no new keys only.- All three are shallow. Nested objects need recursive handling.
- Enumerable controls visibility in
Object.keys, iteration, JSON, spread, andObject.assign. - Configurable controls permanence — once
false, the property cannot be reconfigured.
Most code does not need to touch this API. Knowing it exists explains: why class methods don't show in Object.keys, why Object.freeze is shallow, why object spread sometimes misses fields, and how reactive libraries work.
What's next #
That completes Module 3. Module 4 picks up with prototype-based programming and classes — building on the property and inheritance concepts we touched on here.
Try it yourself #
The defineProperty defaults are the most surprising bit. Predict the output:
const obj = {};
Object.defineProperty(obj, 'x', { value: 1 });
obj.x = 99;
console.log(obj.x);
console.log(Object.keys(obj));js_sandboxOutput:1 (assignment was silently ignored — writable defaulted to false)[] (the property is non-enumerable — enumerable defaulted to false)If you’d written
obj.x = 1 directly, both would be true. defineProperty‘s opposite defaults catch every JS developer at least once.When using defineProperty, always spell out every flag you want as true. The silent defaults are the whole footgun.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


