Skip to content

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 via obj.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 reads obj.field. Its return value is what the reader sees.
  • set - the function called when someone writes obj.field = newValue. It receives newValue as 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 in for...in, Object.keys, spread ({...obj}), JSON.stringify.
  • configurable - whether you can later change the property’s attributes or delete it altogether via delete.

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: x

For 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 ran

Two key use cases:

  1. Computed properties - a value derived from other fields (like fullName above).
  2. 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:

  1. Every object property has 4 flags under the hood: writable, enumerable, configurable, plus either value or a get/set pair. By default (when you write obj.x = 5), all three flags are true.
  2. Object.defineProperty defaults flags to false. If you use it and want a “regular” property, add writable: true, enumerable: true, configurable: true.
  3. 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.

← Back to notes