Property Descriptors and Object Immutability
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.
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
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: falseon 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: falseANDconfigurable: falseon all data properties
const obj = { a: 1 };
Object.freeze(obj);
obj.a = 10; // Fails
obj.b = 2; // Fails
delete obj.a; // Fails
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;
}
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 do | What 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 |
- 1Every property has hidden metadata: writable, enumerable, and configurable flags
- 2Object.defineProperty defaults flags to false. Normal assignment defaults to true.
- 3Object.freeze is shallow — nested objects remain mutable. Use deepFreeze for full immutability.
- 4configurable: false is permanent. The only exception: writable can change from true to false (one-way).
- 5Three lockdown levels: preventExtensions (no add) < seal (no add/delete) < freeze (no add/delete/modify)