Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable

scipio(71)
Published in
#blog
Words
2999
Reading
14 min
Listen
Play
9h

Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable

js-banner.png

What will I learn

  • You will learn that every property carries three hidden flags that control how it behaves;
  • what writable, enumerable, and configurable each do, and why the defaults matter;
  • how to inspect a property's descriptor, and why normal assignment always sets the "all true" defaults;
  • how to define a property precisely with Object.defineProperty (and the trap that catches everyone);
  • practical uses: read-only constants on an object, hidden internal fields, and locking an API down for good.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-31 read, especially objects (ep12) and object literals (ep31).

Difficulty

  • Beginner

Curriculum (of the Learn JS Series):

Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable

At the end of last episode I made you a promise. We had taken the object literal about as far as its surface syntax goes -- shorthand, computed keys, spread -- and I said that every single property we had ever created was quietly hiding something underneath. Today we lift the lid. This is the layer beneath everything you have done with objects so far, and once you can see it, a whole pile of otherwise-mysterious behaviour suddenly makes sense: why the built-in array methods never show up when you loop over an array, how a library gives you a value you are not allowed to overwrite, how a const-like guarantee can be attached to a property (not just a variable), and how tools "lock down" an object so nobody can tamper with it.

It is worth being clear up front about the register we are working in. You will not reach for descriptors on most days -- ordinary properties, all flags flexible, are exactly what you want the overwhelming majority of the time. But knowing this machinery exists, and knowing precisely how it works, is the difference between someone who uses objects and someone who understands them. And it is the direct foundation of the next episode's topic, so let's earn it properly.

Solutions to Episode 31 Exercises

Exercise 1 - a user with shorthand and a method:

function makeUser(name, age) {
  return {
    name,
    age,
    describe() { return `${this.name} is ${this.age} years old`; },
  };
}
console.log(makeUser("scipio", 30).describe()); // "scipio is 30 years old"

The insight: shorthand name/age keep the literal compact, and the shorthand method describe() is a regular function, so this inside it is the object it was called on (rule 1 of the five this rules from episode 22). An arrow function here would have grabbed the wrong this.

Exercise 2 - a computed single-property object, then fromPairs:

function pair(key, value) {
  return { [key]: value };
}
console.log(pair("color", "blue")); // { color: 'blue' }

function fromPairs(pairs) {
  const result = {};
  for (const [k, v] of pairs) {
    result[k] = v; // bracket assignment with a runtime key
  }
  return result;
}
console.log(fromPairs([["a", 1], ["b", 2]])); // { a: 1, b: 2 }

The insight: the computed key [key] turns a variable's value into the property name. fromPairs shows the loop-based cousin -- bracket assignment does the same job when you build the object up gradually.

Exercise 3 - spread-based updates, and the integer-key ordering:

const base = { a: 1, b: 2 };
console.log({ ...base, c: 3 });  // { a: 1, b: 2, c: 3 }
console.log({ ...base, b: 20 }); // { a: 1, b: 20 } - later key wins
console.log(base);               // { a: 1, b: 2 } - original untouched

const weird = { ...base, ["0"]: "zero" };
console.log(Object.keys(weird)); // ["0", "a", "b"] - "0" sorts to the front

The insight: spread copies into a brand-new object and later keys override earlier ones, so the original is never mutated. And an integer-like key such as "0" jumps to the front of the key order, sorted numerically, regardless of when you added it -- exactly the surprise we ended episode 31 on.

Right, solutions done. Now we go beneath the surface of a property, because a property is quite some more than just a key and a value.

Every property has three hidden flags

When you write obj.name = "scipio", it looks like you are simply storing a value under a key. But behind every property sits a small internal record called a property descriptor. That record holds not only the value, but three boolean flags that decide how the property is allowed to behave:

  • writable -- can the value be changed by assignment?
  • enumerable -- does the property show up in loops, Object.keys, JSON.stringify, and spread?
  • configurable -- can the property be deleted, or its flags changed later?

You can see this record with Object.getOwnPropertyDescriptor(obj, key):

const user = { name: "scipio" };
console.log(Object.getOwnPropertyDescriptor(user, "name"));
// { value: 'scipio', writable: true, enumerable: true, configurable: true }

So user.name is not just the string "scipio". It is a value plus three flags, and when you create a property the ordinary way, all three come out true. That "all true" default is precisely what makes normal properties feel the way you expect them to: you can reassign them, they turn up in your loops, and you can delete them. Nothing about a plain property is locked.

(There are actually two kinds of descriptor: this data descriptor -- a value plus writable -- and an accessor descriptor, which swaps value/writable for a get/set pair. We are focused on data descriptors today; accessor descriptors are the whole story of the next episode, so keep that word in the back of your mind.)

Normal assignment sets everything to true

Here is the baseline you must have burned in, because the alternative in a moment does the opposite. When you create a property in a literal or by plain assignment, all three flags default to true:

const config = {};
config.debug = true; // ordinary assignment

const desc = Object.getOwnPropertyDescriptor(config, "debug");
console.log(desc.writable, desc.enumerable, desc.configurable); // true true true

Same story inside a literal -- { debug: true } produces exactly the same all-true descriptor. This is why the properties you write every day are fully flexible: changeable, visible, deletable. To make a property that behaves differently, you cannot use assignment or a literal, because both force all-true. You need a more precise tool.

Object.defineProperty: precise control (and its trap)

Object.defineProperty(obj, key, descriptor) lets you define a property with exactly the flags you choose. And here is the catch that trips up nearly everyone the first time: when you use defineProperty, any flag you omit defaults to false -- the exact opposite of ordinary assignment. So you have to be explicit about what you want turned on:

const settings = {};
Object.defineProperty(settings, "version", {
  value: "1.0.0",
  writable: false,     // cannot be reassigned
  enumerable: true,    // but it does show up in loops
  configurable: false, // cannot be deleted or reconfigured
});

console.log(settings.version); // "1.0.0"
settings.version = "2.0.0";    // silently does nothing (throws in strict mode)
console.log(settings.version); // still "1.0.0" - it is genuinely read-only

We just built a read-only property. Assigning to it does nothing at all in normal ("sloppy") code, and throws a TypeError under "use strict" -- more on that difference in a second. This is how you create a real constant on an object, something a plain const cannot do. Remember from episode 2 that const only freezes the binding -- it stops you pointing the variable at a different object, but it does nothing to protect the properties inside that object. writable: false protects the property itself.

To really drive the omitted-flag trap home, look at what happens when you leave everything out except the value:

const obj = {};
Object.defineProperty(obj, "x", { value: 10 }); // only value given
console.log(Object.getOwnPropertyDescriptor(obj, "x"));
// { value: 10, writable: false, enumerable: false, configurable: false }

All three flags came out false, purely because we did not mention them. That property is read-only, invisible to loops, AND permanent -- probably not what a beginner expected from "just define a property with value 10". So the rule to memorise: literal/assignment = all true; defineProperty = omitted means false. When in doubt, spell out all three flags and leave nothing to chance.

writable: read-only values

Let's take the flags one at a time, because each solves a distinct real problem. writable: false makes a value unchangeable after creation. This is exactly right for things that should be fixed for the lifetime of the object -- an ID, a created-at timestamp, a version string:

const account = {};
Object.defineProperty(account, "id", {
  value: 42,
  writable: false,
  enumerable: true, // we still want it visible in Object.keys / JSON
});

account.id = 99;         // ignored (or throws in strict mode)
console.log(account.id); // 42 - protected

Note carefully how this differs from Object.freeze (which we will meet at episode 47). Object.freeze locks down a whole object at once -- every property becomes non-writable and non-configurable in a single call. writable: false is the surgical version: it locks one property's value while leaving the rest of the object completely normal. When you only need to nail one field down, the surgical tool is the honest choice; reach for freeze when you genuinely want the entire object immutable.

enumerable: hidden properties

enumerable: false makes a property invisible to enumeration. It will not appear in Object.keys, for...in, JSON.stringify, or object spread -- yet it remains perfectly accessible if you know its name. This is how a property can exist and do its job while staying "behind the scenes":

const record = { visible: 1 };
Object.defineProperty(record, "hidden", { value: 2, enumerable: false });

console.log(record.hidden);          // 2 - still readable directly
console.log(Object.keys(record));    // ["visible"] - 'hidden' is not listed
console.log(JSON.stringify(record)); // {"visible":1} - 'hidden' is skipped
console.log({ ...record });          // { visible: 1 } - spread skips it too

This is not some obscure corner -- it is everywhere in the language, you just never saw it. When you loop over an array or an object, you do not see the dozens of inherited methods (push, map, toString, hasOwnProperty, and the rest) mixed in with your data. Why not? Because every one of those built-in methods is defined as non-enumerable. It was a deliberate design decision so that enumeration stays focused on your data and not on the machinery underneath it. Try it -- add a method to an array and watch Object.keys stay clean while the method still works:

const nums = [10, 20, 30];
Object.defineProperty(nums, "sum", {
  value() { return this.reduce((a, b) => a + b, 0); },
  enumerable: false, // stays out of for...in / Object.keys
});

console.log(nums.sum());        // 60 - the method works
console.log(Object.keys(nums)); // ["0", "1", "2"] - 'sum' is invisible
for (const key in nums) console.log(key); // 0, 1, 2 - no 'sum'

That is precisely the trick the JavaScript engine itself uses for its own methods. Non-enumerable does not mean private and it does not mean inaccessible -- nums.sum is right there if you ask for it by name. It only means "do not show up when someone enumerates". Keep that distinction sharp, because it is a common source of confusion: hidden-from-loops is a very different thing from truly-private (which we get to later in the phase, with the # syntax).

configurable: locking a property down

configurable: false is the strictest of the three, and the one to treat with the most respect. Once a property is non-configurable you can no longer delete it, and you can no longer change its other flags -- with one narrow exception: you may still flip writable from true down to false (tightening is allowed, loosening is not). It makes a property effectively permanent:

const locked = {};
Object.defineProperty(locked, "key", { value: "secret", configurable: false });

delete locked.key;       // fails silently (throws in strict mode)
console.log(locked.key); // "secret" - it cannot be deleted

// Trying to redefine its flags now throws a TypeError:
try {
  Object.defineProperty(locked, "key", { enumerable: true });
} catch (e) {
  console.log(e.constructor.name); // "TypeError"
}

Here is the part people forget: configurable: false is a one-way door. There is no method, no trick, no clever re-definition that turns it back on. Once you set it, that property's shape is frozen for the rest of the program. That makes it the right tool when you genuinely want a property to be tamper-resistant forever -- a security-sensitive setting, a fixed protocol constant -- but it is also a promise you can never take back, so spend it deliberatly. If you are not certain you want permanence, leave configurable as true.

strict mode changes the failure, not the flags

I have kept saying "silently ignored (throws in strict mode)", so let me make that concrete, because it genuinely matters in modern code. In sloppy mode, an illegal write to a non-writable property, or a delete of a non-configurable one, just quietly does nothing -- which can hide bugs for hours. Under "use strict" (and note: ES modules and class bodies are strict automatically), the same operations throw a TypeError immediately:

"use strict";
const frozenish = {};
Object.defineProperty(frozenish, "pi", { value: 3.14159, writable: false });

try {
  frozenish.pi = 3; // in strict mode this THROWS instead of failing silently
} catch (e) {
  console.log(e.constructor.name); // "TypeError"
}
console.log(frozenish.pi); // 3.14159

The flags are identical either way -- strict mode does not change what is protected, only how loudly a violation fails. Since most code you write today lives in modules (which are strict by default), you should expect the loud version. That is a good thing: failing loudly beats failing silently every single time.

Reading and defining several at once

Two more built-ins round out the toolkit. To define multiple properties with custom descriptors in a single call, use Object.defineProperties. To read all of an object's descriptors at once, use Object.getOwnPropertyDescriptors (plural):

const api = {};
Object.defineProperties(api, {
  name:   { value: "widget", enumerable: true, writable: false },
  secret: { value: 123, enumerable: false },
});

console.log(Object.keys(api)); // ["name"] - 'secret' is non-enumerable
console.log(api.secret);       // 123 - but still accessible by name

console.log(Object.getOwnPropertyDescriptors(api));
// {
//   name:   { value: 'widget', writable: false, enumerable: true, configurable: false },
//   secret: { value: 123, writable: false, enumerable: false, configurable: false }
// }

The plural getOwnPropertyDescriptors is genuinely useful for one everyday task in particular: making a faithful copy of an object. Plain spread ({ ...obj }) copies values but throws the flags away -- every property in the copy comes out all-true, so getters become plain values and hidden properties either vanish or become visible. If you need to clone an object including its descriptors, the standard idiom pairs it with Object.create:

const original = {};
Object.defineProperty(original, "id", { value: 7, enumerable: false });

const shallowCopy = { ...original };
console.log("id" in shallowCopy); // false - spread skipped the hidden prop!

const faithfulCopy = Object.create(
  Object.getPrototypeOf(original),
  Object.getOwnPropertyDescriptors(original)
);
console.log(faithfulCopy.id); // 7 - descriptors preserved

Do not worry about Object.create and getPrototypeOf in detail yet -- they are the subject of the coming episodes on the prototype system. For now just file away the pattern: spread copies values, getOwnPropertyDescriptors + Object.create copies values and their flags.

A quick look sideways: how Python does it

Since quite some of you came here from the Learn Python Series, a short comparison helps show that this is a general idea, not a JavaScript oddity. Python does not attach three boolean flags to every attribute the way JS does. Instead it reaches for the same goals through different mechanisms: property (a descriptor object) for computed/controlled attributes, __slots__ to restrict which attributes may exist, and types.MappingProxyType for a read-only view of a dict.

from types import MappingProxyType

data = {"version": "1.0.0", "debug": True}
readonly = MappingProxyType(data)   # a read-only *view* of the dict

print(readonly["version"])          # 1.0.0 - reading is fine
try:
    readonly["version"] = "2.0.0"   # writing raises TypeError
except TypeError as e:
    print("blocked:", e)

The shapes differ -- Python guards the container or defines attribute behaviour with descriptor objects, JavaScript tags each property with flags -- but the underlying needs are identical: some values must be read-only, some must stay out of the way, some must be nailed down permanently. Learn the concept once and you recognise its cousin in whatever language you land in next. Having said that, the JS model is unusually fine-grained -- per-property, three independent switches -- which is exactly why it can express things like "visible but read-only" or "writable but hidden" so cleanly.

When you actually use this

Let me be honest about the day-to-day, because I do not want you sprinkling defineProperty all over ordinary code -- that would make it harder to read, not better. Most properties should stay plain and flexible. You reach for descriptors in a handful of specific situations:

  • Library and framework internals -- attaching a housekeeping field to an object without polluting the user's Object.keys or JSON.stringify output (non-enumerable is perfect here).
  • Genuine per-property constants -- an id, a createdAt, a protocol version that must never change (non-writable).
  • Tamper-resistant configuration -- values that must survive for the life of the program no matter what other code tries (non-configurable, the one-way door).
  • Faithful cloning and metaprogramming -- reading descriptors to reproduce an object exactly, flags and all.

Knowing they exist also demystifies behaviour you have been taking for granted: why built-in methods stay out of your loops, how a "frozen" object refuses writes, why some library object has a field you can read but not overwrite. That understanding is worth far more than the raw ability to type Object.defineProperty -- it is what turns objects from magic into mechanism.

Try it yourself

  1. Create an object and add a read-only property pi with the value 3.14159 using Object.defineProperty (set writable: false). Try to reassign it and confirm the value does not change, then read its descriptor and check that writable is indeed false. For bonus points, wrap the reassignment in "use strict"; and observe the TypeError.
  2. Create an object with one normal property and one non-enumerable property. Print Object.keys, JSON.stringify(obj), and a direct access to the hidden property, and explain in a comment what enumeration hides that direct access still reveals.
  3. Define a property giving ONLY its value (omit all three flags), then inspect all three flags with Object.getOwnPropertyDescriptor. Explain in a comment why they came out the way they did, and contrast that with the descriptor you get from a plain obj.x = ... assignment.

So what did we actually cover?

  • Every property is a descriptor: a value plus three flags -- writable, enumerable, and configurable.
  • Normal assignment and object literals set all three flags to true; Object.defineProperty defaults every omitted flag to false (the trap that catches beginners).
  • writable: false makes a value read-only -- a true per-property constant, more surgical than Object.freeze.
  • enumerable: false hides a property from for...in, Object.keys, JSON.stringify, and spread, while keeping it directly accessible -- this is exactly how built-in methods stay out of your loops (hidden is NOT the same as private).
  • configurable: false permanently blocks deleting the property or changing its flags -- a one-way door you cannot undo (you may still tighten writable from true to false).
  • Strict mode turns silent failures into loud TypeErrors; it changes the failure, not the flags.
  • Object.defineProperties defines many at once; Object.getOwnPropertyDescriptors reads them all, which (with Object.create) lets you copy an object faithfully -- flags and all -- where plain spread cannot.

We spent this whole episode on data descriptors -- value plus writable. But I mentioned there is a second kind, the accessor descriptor, which trades the value for a get/set pair. That is where descriptors show their most elegant feature: functions that masquerade as ordinary properties, letting you compute a value on read and validate one on write, all while the caller thinks they are just touching a plain field. That is exactly where we go next.

Thanks for reading -- see you in the next one!

scipio@scipio