Property flags and descriptors

You already know objects hold properties, and up to now a property has looked like a plain key-value pair: user.name is "Maya", end of story. That picture is incomplete. Behind each property sits a small block of configuration that controls whether the value can change, whether loops can see it, and whether the property can be reconfigured or deleted at all.

This lesson pulls back that curtain. Once you can read and set those hidden controls, you can build read-only constants, hide helper methods from for..in, and freeze whole objects so nothing about them can shift. The next chapter builds on the same machinery to turn a property into a pair of getter/setter functions.

Property flags

A regular data property stores a value, and alongside it three boolean attributes — the course calls them “flags”:

  • writable — when true, the value can be reassigned. When false, the property is read-only.
  • enumerable — when true, the property shows up in for..in loops and Object.keys. When false, it stays out of them.
  • configurable — when true, the property can be deleted and its flags can be edited. When false, that door locks.

You haven’t run into these because they normally stay invisible. Create a property the ordinary way — user.name = "Maya" or a literal { name: "Maya" } — and all three flags come out true. They’re there; they just don’t advertise themselves. And you can change any of them whenever you like.

value
“Maya”
writable
true
enumerable
true
configurable
true
A data property is a value plus three flags. Creating one 'the usual way' turns every flag on.

Start by reading the flags. The method Object.getOwnPropertyDescriptor hands you the full record for a single property.

let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);

It takes two arguments: obj, the object you’re inspecting, and propertyName, the key you want to know about. What comes back is a property descriptor — a plain object holding the value together with every flag.

let user = {
  name: "Maya"
};

let descriptor = Object.getOwnPropertyDescriptor(user, 'name');

alert( JSON.stringify(descriptor, null, 2 ) );
/* property descriptor:
{
  "value": "Maya",
  "writable": true,
  "enumerable": true,
  "configurable": true
}
*/

To write flags instead of reading them, reach for Object.defineProperty.

Object.defineProperty(obj, propertyName, descriptor)

You pass the target obj, the propertyName you’re defining, and a descriptor object describing the value and flags to apply. The behavior splits two ways:

  • If the property already exists, defineProperty updates only the flags you list and leaves the rest alone.
  • If the property doesn’t exist yet, it creates one. And here’s the catch that trips people up: any flag you leave out defaults to false, not true.

That default is the opposite of what the literal syntax does. Watch what happens when you create name through defineProperty and mention only the value:

let user = {};

Object.defineProperty(user, "name", {
  value: "Maya"
});

let descriptor = Object.getOwnPropertyDescriptor(user, 'name');

alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
  "value": "Maya",
  "writable": false,
  "enumerable": false,
  "configurable": false
}
 */

Compare that to the user.name from a literal earlier, where every flag was true. Here all three came out false because you didn’t ask for them. When you build a property with defineProperty and want it to behave like a normal one, spell out writable: true, enumerable: true, configurable: true yourself.

{ name: “Maya” }
literal / assignment
writable: true
enumerable: true
configurable: true
defineProperty(…{ value })
flags omitted
writable: false
enumerable: false
configurable: false
Same value, opposite defaults — how you create the property decides the flags.

With reading and writing covered, walk through what each flag actually does.

Non-writable

Flip writable to false and the property becomes read-only — reassignment stops working.

let user = {
  name: "Maya"
};

Object.defineProperty(user, "name", {
  writable: false
});

user.name = "Raj"; // Error: Cannot assign to read only property 'name'

From now on nobody can rename this user — unless they run their own defineProperty to reverse the flag, which the still-true configurable flag permits here.

You can build the read-only property from scratch too. Just remember the false-by-default rule and switch on whatever else you need:

let user = { };

Object.defineProperty(user, "name", {
  value: "Maya",
  // for new properties we need to explicitly list what's true
  enumerable: true,
  configurable: true
});

alert(user.name); // Maya
user.name = "Raj"; // Error

Non-enumerable

Add a method of your own to an object and it shows up in loops by default. The engine’s built-in toString doesn’t — it’s created non-enumerable, so for..in skips it. Your custom toString gets no such treatment:

let user = {
  name: "Maya",
  toString() {
    return this.name;
  }
};

// By default, both our properties are listed:
for (let key in user) alert(key); // name, toString

If you’d rather keep toString out of the loop — it’s plumbing, not data — set enumerable: false and it drops out, matching how the built-in behaves:

let user = {
  name: "Maya",
  toString() {
    return this.name;
  }
};

Object.defineProperty(user, "toString", {
  enumerable: false
});

// Now our toString disappears:
for (let key in user) alert(key); // name

The same flag governs Object.keys, so it also drops the hidden property:

alert(Object.keys(user)); // name
for..in / Object.keys sees
name
hidden but present
toString ← user.toString() still works
enumerable: false hides the property from iteration, but it's still there and still readable.

Try it live. Toggle the enumerable flag on toString, then run a for..in — the key appears and disappears while user.toString() keeps working regardless.

interactiveHiding a key from for..in

Non-configurable

The configurable: false flag is the strict one. Some built-in objects ship with it preset. Once a property is non-configurable, you can’t delete it and you can’t edit its attributes — the flags freeze in place.

Math.PI is a textbook example. It’s non-writable, non-enumerable, and non-configurable all at once:

let descriptor = Object.getOwnPropertyDescriptor(Math, 'PI');

alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
  "value": 3.141592653589793,
  "writable": false,
  "enumerable": false,
  "configurable": false
}
*/

So there’s no way to reassign or redefine Math.PI in your code:

Math.PI = 3; // Error, because it has writable: false

// delete Math.PI won't work either

And because it’s locked, you can’t even talk it into becoming writable again:

// Error, because of configurable: false
Object.defineProperty(Math, "PI", { writable: true });

Math.PI is untouchable — value, flags, existence, all sealed.

The key thing to internalize: making a property non-configurable is a one-way trip. defineProperty can’t undo it. There is no configurable: true that brings the flexibility back.

Here user.name is non-configurable but stays writable, so the value still moves while deletion fails:

let user = {
  name: "Maya"
};

Object.defineProperty(user, "name", {
  configurable: false
});

user.name = "Raj"; // works fine
delete user.name; // Error

Combine both flags and you get a permanent constant, the same shape as Math.PI:

let user = {
  name: "Maya"
};

Object.defineProperty(user, "name", {
  writable: false,
  configurable: false
});

// won't be able to change user.name or its flags
// all this won't work:
user.name = "Raj";
delete user.name;
Object.defineProperty(user, "name", { value: "Raj" });

Put all three flags under your fingers at once. Pick a combination, define user.name, then try to reassign it, delete it, or list the keys — the outcomes below are exactly what the flags dictate. (The demo runs in strict mode, so blocked operations report a real TypeError instead of failing silently.)

interactiveFlag lab: define, then probe

Object.defineProperties

Defining properties one at a time gets tedious. Object.defineProperties(obj, descriptors) sets several in a single call.

Object.defineProperties(obj, {
  prop1: descriptor1,
  prop2: descriptor2
  // ...
});

Each key maps to its own descriptor:

Object.defineProperties(user, {
  name: { value: "Maya", writable: false },
  surname: { value: "Vance", writable: false },
  // ...
});

One call, many properties, each with the exact flags you specify.

Object.getOwnPropertyDescriptors

The counterpart to reading a single descriptor is Object.getOwnPropertyDescriptors(obj), which returns descriptors for every property at once. Pair it with Object.defineProperties and you get a clone that preserves flags:

let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj));

Why bother? The clone you usually write copies values through assignment:

for (let key in user) {
  clone[key] = user[key]
}

That loop throws the flags away. Every copied property lands with the default true flags regardless of the original — a non-writable field becomes writable in the copy. The descriptor-based approach carries the flags across untouched.

for..in + assignment

✗ flags reset to defaults
✗ skips non-enumerable keys
✗ skips symbolic keys

getOwnPropertyDescriptors

✓ flags copied exactly
✓ includes non-enumerable keys
✓ includes symbolic keys

Two ways to clone. Only the descriptor route preserves flags and hidden keys.

That second advantage matters as much as the first: for..in ignores symbolic properties and non-enumerable ones, so an assignment-based clone silently loses them. getOwnPropertyDescriptors reports all of a property’s descriptors, symbols and hidden keys included, so nothing slips through.

Sealing an object globally

Everything so far works property by property. Three more methods clamp down on the whole object at once, controlling whether its shape can grow, shrink, or change:

  • Object.preventExtensions(obj) — blocks adding new properties. Existing ones stay editable.
  • Object.seal(obj) — blocks adding and removing properties, and sets configurable: false on everything already there. Values can still change if they’re writable.
  • Object.freeze(obj) — the hardest lock: no adding, no removing, no changing. It sets both configurable: false and writable: false across the board.

Each has a matching test that reports the object’s current state:

  • Object.isExtensible(obj) — returns false when new properties are forbidden, otherwise true.
  • Object.isSealed(obj) — returns true when extensions are blocked and every property is configurable: false.
  • Object.isFrozen(obj) — returns true when extensions are blocked and every property is both configurable: false and writable: false.
preventExtensions
add new ✗   delete   change value
seal
add new ✗   delete ✗   change value
freeze
add new ✗   delete ✗   change value ✗
Three levels of whole-object lockdown, from loosest to tightest.

Lock a config object at each level and then poke at it. Apply preventExtensions, seal, or freeze, then try to add, change, or delete a property — the badge shows the object’s reported state and each attempt reports whether the engine let it through.

interactivepreventExtensions vs seal vs freeze

In practice you’ll reach for these rarely. Object.freeze shows up now and then to protect a config object or a shared constant from accidental mutation; the rest are mostly things you’ll recognize when you meet them rather than tools you keep on your belt.