Property descriptors and getters/setters in JavaScript
Every object property in JS carries 4 flags under the hood (writable, enumerable, configurable, plus value or get/set). You rarely think about them - until something odd happens: an assignment doesn't change the value, delete doesn't remove the field, for...in skips the property. A note on what property descriptors are, how getters/setters work, and why Object.defineProperty has different defaults than an object literal.
Look at this code:
const config = {};
Object.defineProperty(config, "apiUrl", {
value: "https://api.example.com",
});
config.apiUrl = "https://malicious.com";
console.log(config.apiUrl); // "https://api.example.com" (!)The assignment didn’t change the value. No error, no warning. This isn’t a bug - it’s a property descriptor with writable: false.
Under every property of an object in JS, the engine keeps not just the value, but also several flags that decide what’s allowed: whether you can overwrite it, whether it shows up in for...in, whether you can delete it, whether it has a getter and setter instead of a value. Those flags make up a property descriptor - an object describing the property. You don’t peek into it day to day, but several language mechanisms (e.g. Object.freeze, classes, private fields) rely on it directly.
#Four flags (and two kinds of descriptor)
The ECMA-262 spec defines two kinds of descriptor - depending on how you want the property to behave.
Data descriptor is a regular property that stores a value. It has two attributes:
value- the actual value of the property (e.g.5,"hello", some array).writable- whether an assignment viaobj.field = ...is allowed to succeed.
Accessor descriptor is a property that, instead of a static value, holds a pair of functions called automatically on read and write:
get- the function called when someone readsobj.field. Its return value is what the reader sees.set- the function called when someone writesobj.field = newValue. It receivesnewValueas an argument.
Each property’s descriptor is of one of these two kinds - data or accessor, never both. Trying to provide both value and get at the same time throws a TypeError.
Plus two attributes shared by both kinds:
enumerable- whether the property shows up infor...in,Object.keys, spread ({...obj}),JSON.stringify.configurable- whether you can later change the property’s attributes or delete it altogether viadelete.
You can inspect the descriptor of any existing property with Object.getOwnPropertyDescriptor:
const obj = { name: "Adam" };
Object.getOwnPropertyDescriptor(obj, "name");
// { value: "Adam", writable: true, enumerable: true, configurable: true }#Pitfall 1: defineProperty has different defaults than an object literal
The most common pitfall - and the reason the example from the hook “didn’t work”:
// obj.x = 5 or { x: 5 }
{ value: 5, writable: true, enumerable: true, configurable: true }
// Object.defineProperty(obj, "x", { value: 5 })
{ value: 5, writable: false, enumerable: false, configurable: false }A property without writable: true can’t be overwritten (in non-strict mode the assignment fails silently, in strict mode it throws TypeError). Without enumerable: true the property won’t show up in Object.keys, for...in, spread, JSON.stringify. Without configurable: true you can’t delete it or change its flags.
If you use Object.defineProperty and want a “regular” property, set all three flags to true explicitly.
#Pitfall 2: configurable: false is one-way
Once you set configurable: false, you can’t undo it. The spec deliberately makes this irreversible - otherwise the entire semantics of a “frozen” property would be circumventable:
const obj = {};
Object.defineProperty(obj, "x", { value: 1, configurable: false });
Object.defineProperty(obj, "x", { configurable: true });
// TypeError: Cannot redefine property: xFor a data descriptor with writable: true there’s one exception - you can still change value and toggle writable to false (but not back to true). For an accessor descriptor, or writable: false, nothing can be changed.
Object.freeze(obj) is, in effect, a shorthand: it sets writable: false and configurable: false on every own property of the object.
#Getters and setters - when to actually reach for them
Accessor descriptors (get / set) are a special case. The syntax in object literals and classes is natural enough that you often forget there’s a descriptor underneath:
const user = {
firstName: "Adam",
lastName: "Nowak",
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
set fullName(value) {
[this.firstName, this.lastName] = value.split(" ");
},
};
user.fullName; // "Adam Nowak" - getter ran
user.fullName = "Jan Kowalski"; // setter ranTwo key use cases:
- Computed properties - a value derived from other fields (like
fullNameabove). - Validation on write - the setter checks the value before assigning (e.g. age must be non-negative, email must match a regex).
The classic pitfall: infinite recursion in a setter. The setter is called on every assignment attempt - including the assignment you make inside the setter:
const obj = {
set value(v) {
this.value = v; // ← calls itself. RangeError: Maximum call stack
},
};Fix: keep the internal value under a different name (_value) or in a private class field (#value).
#What to take away
Three things worth keeping in mind:
- Every object property has 4 flags under the hood:
writable,enumerable,configurable, plus eithervalueor aget/setpair. By default (when you writeobj.x = 5), all three flags aretrue. Object.definePropertydefaults flags tofalse. If you use it and want a “regular” property, addwritable: true, enumerable: true, configurable: true.- Getters/setters are accessor descriptors. Reach for them for computed properties and write-time validation. Remember that assigning to the same field name inside the setter calls it forever.
Day-to-day code rarely touches Object.defineProperty directly. But you now know why Object.freeze freezes and why Symbol.iterator doesn’t show up in Object.keys (because enumerable: false) - all uses of the same mechanism.