Skip to content

Property Descriptors and Object Immutability

intermediate10 min read

Properties Are More Than Key-Value Pairs

When you write obj.name = "Alice", you're not just storing a value. You're creating a property with hidden metadata — three boolean flags that control whether the property can be changed, deleted, or seen in loops. Most developers never touch these flags, which means they miss a powerful tool for defensive programming and can't explain why Object.freeze doesn't actually make objects immutable.

Mental Model

Think of each property as a file in an operating system. The file has content (the value), but it also has permissions: read-only (writable), hidden from directory listings (enumerable), and locked from deletion or permission changes (configurable). Just like file permissions, these flags are independent — a property can be writable but not enumerable, or enumerable but not configurable.

The Property Descriptor Model

Every property has a descriptor — an object describing the property's metadata. There are two types:

Data Descriptors

const obj = {};
Object.defineProperty(obj, "name", {
  value: "Alice",
  writable: true,      // Can the value be changed?
  enumerable: true,    // Does it show up in for...in and Object.keys()?
  configurable: true   // Can the descriptor be modified? Can the property be deleted?
});

Accessor Descriptors

const obj = {};
let _name = "Alice";
Object.defineProperty(obj, "name", {
  get() { return _name; },
  set(value) { _name = value; },
  enumerable: true,
  configurable: true
  // No 'value' or 'writable' — accessor descriptors use get/set instead
});

A property is either a data descriptor or an accessor descriptor. Never both. You can't have value and get on the same property.

The Three Flags

writable — Can the Value Change?

const obj = {};
Object.defineProperty(obj, "id", {
  value: 42,
  writable: false, // read-only
  enumerable: true,
  configurable: true
});

obj.id = 100;
console.log(obj.id); // 42 — silent failure in sloppy mode
// In strict mode: TypeError: Cannot assign to read-only property
Common Trap

In sloppy mode, writing to a non-writable property silently fails. No error, no warning — the value just stays the same. This is one of the strongest arguments for strict mode. In strict mode, you get a clear TypeError.

enumerable — Is the Property Visible?

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

Object.keys(obj);          // ["a"] — hidden is not listed
JSON.stringify(obj);        // '{"a":1}' — hidden excluded
for (const key in obj) {}   // Only iterates "a"

// But it's still accessible:
obj.hidden;                 // 2
Object.getOwnPropertyNames(obj); // ["a", "hidden"] — shows ALL own properties

This is how built-in methods like Array.prototype.push stay invisible in for...in loops — they're non-enumerable.

configurable — Can the Descriptor Be Modified?

const obj = {};
Object.defineProperty(obj, "locked", {
  value: 42,
  writable: true,
  enumerable: true,
  configurable: false // Can't change descriptor or delete property
});

// This still works (writable is true):
obj.locked = 100;

// But these fail:
delete obj.locked; // false (silently fails) or TypeError (strict)
Object.defineProperty(obj, "locked", { enumerable: false }); // TypeError
Object.defineProperty(obj, "locked", { configurable: true }); // TypeError

// One exception: you CAN change writable from true to false
// (but not back to true once configurable is false)
Object.defineProperty(obj, "locked", { writable: false }); // OK
Object.defineProperty(obj, "locked", { writable: true });  // TypeError
Why can writable change from true to false?

This one-way transition exists for a practical reason: it allows you to first build an object with writable properties, then lock them down permanently. Once configurable: false and writable: false, the property is truly immutable — you can't change its value, you can't delete it, and you can't change any flags. The one-way nature prevents anyone from undoing the lock.

Default Values Matter

When you create properties normally vs with defineProperty, the defaults are different:

// Normal assignment — all flags default to true
const obj = {};
obj.name = "Alice";
Object.getOwnPropertyDescriptor(obj, "name");
// { value: "Alice", writable: true, enumerable: true, configurable: true }

// defineProperty — all flags default to FALSE
Object.defineProperty(obj, "age", { value: 30 });
Object.getOwnPropertyDescriptor(obj, "age");
// { value: 30, writable: false, enumerable: false, configurable: false }

This is a common source of bugs — Object.defineProperty with just { value: x } creates a frozen, hidden, non-deletable property.

Object Immutability Levels

JavaScript gives you three levels of object lockdown, each stricter than the last:

Level 1: Object.preventExtensions(obj)

  • Cannot add new properties
  • CAN modify existing properties
  • CAN delete existing properties
const obj = { a: 1 };
Object.preventExtensions(obj);
obj.b = 2;     // Silently fails (TypeError in strict)
obj.a = 10;    // Works
delete obj.a;  // Works

Level 2: Object.seal(obj)

  • Cannot add new properties
  • Cannot delete existing properties
  • CAN modify values of existing properties
  • Sets configurable: false on all existing properties
const obj = { a: 1 };
Object.seal(obj);
obj.a = 10;    // Works (writable is still true)
obj.b = 2;     // Fails
delete obj.a;  // Fails

Level 3: Object.freeze(obj)

  • Cannot add new properties
  • Cannot delete existing properties
  • Cannot modify values of existing properties
  • Sets writable: false AND configurable: false on all data properties
const obj = { a: 1 };
Object.freeze(obj);
obj.a = 10;    // Fails
obj.b = 2;     // Fails
delete obj.a;  // Fails
Execution Trace
preventExtensions
No new properties, but existing ones are fully mutable
Weakest lock
seal
No add, no delete, no reconfigure — but values can change
Medium lock
freeze
Nothing can change — values, structure, and descriptors are all locked
Strongest lock (but still shallow)

The Shallow Freeze Problem

And here's the gotcha that catches everyone. Object.freeze only freezes the top level. Nested objects are still mutable:

const config = Object.freeze({
  database: {
    host: "localhost",
    port: 5432
  }
});

config.database.host = "hacked.com"; // WORKS — nested object is not frozen
config.database = {};                // Fails — top-level property is frozen

Deep Freeze Implementation

function deepFreeze(obj) {
  Object.freeze(obj);
  for (const key of Object.getOwnPropertyNames(obj)) {
    const value = obj[key];
    if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return obj;
}
Performance consideration

Deep freezing large object trees has a cost — it walks every property of every nested object. In production, libraries like Immer or Immutable.js provide structural sharing for efficient immutable data. Don't deep-freeze your entire state tree.

Production Scenario: Protecting Configuration

// Without freeze — any module can accidentally mutate shared config
export const config = {
  apiUrl: "https://api.example.com",
  retries: 3,
};
// Somewhere else: config.retries = 0; — silent bug

// With freeze — mutations fail loudly in strict mode
export const config = Object.freeze({
  apiUrl: "https://api.example.com",
  retries: 3,
});
// config.retries = 0; → TypeError in strict mode
What developers doWhat they should do
Assuming Object.freeze makes objects deeply immutable
Freeze only affects own, top-level properties. Implement deepFreeze or use a library for full immutability.
Object.freeze is shallow — nested objects are still mutable
Using Object.defineProperty without specifying all flags
defineProperty defaults all unspecified flags to false, unlike normal assignment which defaults to true
Always specify writable, enumerable, and configurable explicitly
Relying on silent failure of frozen objects in sloppy mode
In sloppy mode, writes to non-writable properties silently fail — bugs go undetected
Always use strict mode so mutations on frozen objects throw TypeError
Trying to change configurable back to true
Once configurable is false, no flag (except writable true→false) can be changed again
configurable: false is permanent — plan your descriptor flags upfront
Quiz
What does Object.defineProperty(obj, 'x', { value: 5 }) create?
Quiz
What's the difference between Object.keys() and Object.getOwnPropertyNames()?
Quiz
After Object.seal(obj), which operation succeeds?
Key Rules
  1. 1Every property has hidden metadata: writable, enumerable, and configurable flags
  2. 2Object.defineProperty defaults flags to false. Normal assignment defaults to true.
  3. 3Object.freeze is shallow — nested objects remain mutable. Use deepFreeze for full immutability.
  4. 4configurable: false is permanent. The only exception: writable can change from true to false (one-way).
  5. 5Three lockdown levels: preventExtensions (no add) < seal (no add/delete) < freeze (no add/delete/modify)