Free lesson · JavaScript Basic to Advance
Property Descriptors and Object.freeze
What's Inside a Property?
Every property on a JavaScript object is more than just a key-value pair. Each property has a hidden set of metadata called a property descriptor that controls how the property behaves — whether it can be changed, whether it shows up in iteration, whether it can be deleted.
const user = { name: "Olena" };
const descriptor = Object.getOwnPropertyDescriptor(user, "name");
console.log(descriptor);
// { value: 'Olena', writable: true, enumerable: true, configurable: true }
Four pieces of metadata for every property:
value— the actual valuewritable— whether the value can be changedenumerable— whether the property shows up inObject.keys,for...in, etc.configurable— whether the property can be deleted or its descriptor changed
When you create a property normally (obj.name = "Olena"), all three boolean flags default to true. This is why regular properties feel like they "just work" — they're fully writable, fully iterable, and fully removable.
But you can change these flags. That's where property descriptors get powerful — and where some of the more obscure JavaScript behaviors come from.
Configuring a Property
The function Object.defineProperty lets you create or modify a property with specific descriptor settings:
const user = {};
Object.defineProperty(user, "name", {
value: "Olena",
writable: false, // can't be changed
enumerable: true, // shows up in keys
configurable: false // can't be deleted or redefined
});
console.log(user.name); // "Olena"
user.name = "Nikhil"; // silently ignored (or throws in strict mode)
console.log(user.name); // still "Olena"
// delete user.name; // throws in strict mode
// Object.defineProperty(user, "name", { value: "Someone Else" }); // throws
The name property exists on user, but it's locked. Reading it works. Writing to it silently fails (or throws in strict mode). It can't be deleted or reconfigured.
This level of control isn't something you'll reach for daily. But knowing it exists helps you understand:
- Why built-in properties don't always behave like normal ones.
Math.PI, for example, haswritable: false. You can't change the value of pi. - Why some properties don't appear in
Object.keys. Built-in methods on prototypes typically haveenumerable: false. - How frameworks implement features like read-only fields and computed properties. They use property descriptors.
Interactive animation: enumerable: false - the property is present and readable, but invisible to Object.keys and loops.
Getters and Setters as Descriptors
We saw getters and setters in Topic 7.3 using the get and set keyword shorthand. They're actually a kind of property descriptor.
const user = {
firstName: "Olena",
lastName: "Marchenko"
};
Object.defineProperty(user, "fullName", {
get() {
return `${this.firstName} ${this.lastName}`;
},
set(value) {
[this.firstName, this.lastName] = value.split(" ");
},
enumerable: true,
configurable: true
});
console.log(user.fullName); // "Olena Marchenko"
user.fullName = "Nikhil Joshi";
console.log(user.firstName); // "Nikhil"
console.log(user.lastName); // "Joshi"
A property with get/set descriptors is called an accessor property (as opposed to a regular data property with value). Accessor properties run code when accessed or assigned, but otherwise behave like normal properties from the outside.
Same effect as the get fullName() {} syntax from Topic 7.3, just defined through the descriptor API.
Iterating Through Descriptors
You can examine all property descriptors of an object at once:
const user = {
name: "Olena",
age: 35
};
const descriptors = Object.getOwnPropertyDescriptors(user);
console.log(descriptors);
// {
// name: { value: 'Olena', writable: true, enumerable: true, configurable: true },
// age: { value: 35, writable: true, enumerable: true, configurable: true }
// }
This is useful for true copying of objects, including their descriptors. The spread operator { ...user } copies values but resets all descriptors to default. To preserve descriptors:
const user = {};
Object.defineProperty(user, "name", { value: "Olena", writable: false });
const shallowCopy = { ...user };
console.log(Object.getOwnPropertyDescriptor(shallowCopy, "name"));
// { value: 'Olena', writable: true, ... } — writability was reset
const propertyCopy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(user));
console.log(Object.getOwnPropertyDescriptor(propertyCopy, "name"));
// { value: 'Olena', writable: false, ... } — preserved
This is rarely needed in day-to-day code, but it matters when working with libraries that rely on descriptor configuration.
Object.freeze
Object.freeze(obj) makes an object immutable. It sets writable: false and configurable: false on every own property. The result: you can't add, change, or remove properties.
const config = {
apiUrl: "https://api.example.com",
retries: 3,
timeout: 5000
};
Object.freeze(config);
config.timeout = 10000; // silently ignored (throws in strict mode)
config.newProperty = "test"; // silently ignored
console.log(config); // unchanged
console.log(Object.isFrozen(config)); // true
After freezing, the object is locked. This is useful for configuration objects, constants, or any object whose contents should never change after creation.
Object.freeze is shallow — it only freezes the immediate properties. If the object contains other objects, those nested objects are not frozen:
const config = {
apiUrl: "https://api.example.com",
features: {
cache: true,
retry: true
}
};
Object.freeze(config);
config.apiUrl = "https://new.example.com"; // ignored — top-level is frozen
config.features.cache = false; // works! features is not frozen
console.log(config);
config.apiUrl is locked, but config.features (the inner object) is not. To freeze deeply, you'd need to recursively freeze every nested object — often called "deep freeze."
function deepFreeze(obj) {
Object.keys(obj).forEach(key => {
const value = obj[key];
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
const config = {
apiUrl: "https://api.example.com",
features: { cache: true, retry: true }
};
deepFreeze(config);
config.features.cache = false; // ignored now — features is also frozen
console.log(config.features.cache); // still true
deepFreeze walks the object recursively and freezes every nested level. Libraries like Lodash's _.cloneDeep and similar utilities provide robust versions of this.
Interactive animation: Locked one level deep - the top level holds, but a nested object can still be changed.
Object.seal and Object.preventExtensions
Two less-strict variants of Object.freeze:
Object.seal(obj) — prevents adding or removing properties, but existing properties can still be modified:
const obj = { count: 0 };
Object.seal(obj);
obj.count = 5; // works — modifying existing
obj.newProp = "x"; // ignored — can't add
// delete obj.count; // ignored — can't remove
console.log(obj);
Object.preventExtensions(obj) — prevents adding new properties, but existing properties can still be modified or removed:
const obj = { count: 0 };
Object.preventExtensions(obj);
obj.count = 5; // works
delete obj.count; // works
obj.newProp = "x"; // ignored
console.log(obj);
The hierarchy from least to most strict:
graph TD
A[preventExtensions<br/>can't add new] --> B[seal<br/>can't add or remove,<br/>can still modify]
B --> C[freeze<br/>can't add, remove, or modify]
style A fill:#10b981,color:#fff
style B fill:#f59e0b,color:#fff
style C fill:#dc2626,color:#fff
This diagram shows the three levels of object lockdown in JavaScript. preventExtensions is the loosest: you can't add new properties, but you can still modify or remove existing ones. seal is stricter: you can't add or remove properties, but you can still modify the values of existing ones. freeze is strictest: you can't add, remove, or modify anything.
In practice, Object.freeze is the one you'll use most often. The other two are useful for specific cases where you want to lock structure but allow value changes.
Common Pitfalls
Interactive animation: The write that quietly bounces - a read-only property ignores the new value without any error.
Warning: The descriptor and freeze traps.
Assuming
Object.freezeis deep. It isn't. Nested objects can still be modified. Use a deep-freeze utility if you need full immutability.Silently failing modifications in non-strict mode. Writing to a frozen property in non-strict mode doesn't throw — it just doesn't do anything. Bugs from this are hard to find. Use strict mode (default in modules and classes — Module 10) to make these errors loud.
Trying to redefine non-configurable properties. Once a property has
configurable: false, you can't change its descriptor. You can't delete it. You can't even revert theconfigurableflag. The lock is permanent.
Why This Matters in Interviews
Interview: Property descriptor questions are uncommon but appear in senior interviews when the interviewer wants to probe depth. "What does
Object.freezeactually do? Is it deep?" tests understanding of the shallow nature offreeze. "How would you make a true constant?" leads to a discussion ofdefinePropertywithwritable: falseandconfigurable: false.The class-related question that comes up: "How would you implement private fields without using
#?" — the answer involves closures (Module 6) but can also touch on property descriptors withenumerable: false.Topic 7.8 — Interview Lens: Objects and Prototypes — covers the deeper interview treatment.
Try It
Exercise: Build a
createConstantsfactory that takes a plain object and returns a frozen version where all properties are non-writable, non-configurable, and non-enumerable.function createConstants(obj) { // your code here } const CONFIG = createConstants({ API_URL: "https://api.example.com", MAX_RETRIES: 3, TIMEOUT: 5000 }); console.log(CONFIG.API_URL); CONFIG.API_URL = "different"; // should have no effect console.log(CONFIG.API_URL); // still the original console.log(Object.keys(CONFIG)); // [] — non-enumerable!The combination of non-writable, non-configurable, AND non-enumerable creates a property that exists, can be accessed, can't be changed, can't be deleted, and doesn't show up in iteration. That's about as locked-down as a JavaScript property gets.