How to Use Property Flags and Descriptors in JavaScript
In JavaScript, every object property is more than just a key-value pair. Behind the scenes, each property carries hidden flags that control how it behaves: whether it can be changed, whether it shows up in loops, and whether it can be deleted or reconfigured. These flags are called property descriptors, and mastering them gives you fine-grained control over your objects.
This guide walks you through property flags, the descriptor API, and powerful techniques like freezing and sealing objects to build truly immutable data structures.
What Are Property Flags?
When you create a regular object property, JavaScript silently attaches three internal flags to it:
| Flag | Description | Default (literal) |
|---|---|---|
writable | Can the value be changed? | true |
enumerable | Does it show up in for...in and Object.keys()? | true |
configurable | Can the property be deleted or its flags modified? | true |
When you write const user = { name: "Alice" }, the name property is writable, enumerable, and configurable by default. This is why you can freely reassign it, loop over it, and delete it.
But what if you want to lock a property down? What if you need a property that exists on the object but is invisible to Object.keys()? That is where descriptors come in.
The writable Flag
Controls whether you can change the property's value after creation.
const user = { name: "Alice" };
user.name = "Bob"; // Works fine (writable is true by default)
console.log(user.name); // "Bob"
When writable is false, any attempt to reassign the value is silently ignored in non-strict mode, or throws a TypeError in strict mode.
The enumerable Flag
Controls whether the property appears in enumeration constructs like for...in loops and Object.keys().
const user = { name: "Alice", age: 30 };
for (let key in user) {
console.log(key); // "name", "age"
}
If enumerable is set to false on age, it would not appear in the loop above, but the property still exists and can be accessed directly.
The configurable Flag
Controls whether the property can be deleted and whether its flags (except writable in one specific case) can be modified. Once configurable is set to false, there is no going back. This is a one-way operation.
Setting configurable: false is irreversible. You cannot reconfigure the property again, not even with Object.defineProperty(). Think of it as sealing the property's configuration permanently.
Reading Property Descriptors with Object.getOwnPropertyDescriptor()
Before modifying flags, you need to inspect them. The Object.getOwnPropertyDescriptor() method returns a descriptor object for a specific property.
const user = {
name: "Alice",
age: 30
};
const descriptor = Object.getOwnPropertyDescriptor(user, "name");
console.log(descriptor);
Output:
{
value: "Alice",
writable: true,
enumerable: true,
configurable: true
}
The returned object has four fields:
value: the current value of the propertywritable: whether the value can be changedenumerable: whether the property is enumerableconfigurable: whether the property can be reconfigured or deleted
If the property does not exist, the method returns undefined:
const descriptor = Object.getOwnPropertyDescriptor(user, "email");
console.log(descriptor); // undefined
This method only works for own properties, not inherited ones. It does not walk the prototype chain.
Setting Property Flags with Object.defineProperty()
To create a property with custom flags or modify the flags of an existing property, use Object.defineProperty().
Syntax:
Object.defineProperty(obj, propertyName, descriptor);
obj: the target objectpropertyName: the name (or Symbol) of the propertydescriptor: the descriptor object with flags and optionally a value
Creating a Non-Writable Property
const user = {};
Object.defineProperty(user, "name", {
value: "Alice",
writable: false,
enumerable: true,
configurable: true
});
console.log(user.name); // "Alice"
user.name = "Bob"; // Silent failure in non-strict mode
console.log(user.name); // "Alice" (unchanged)
In strict mode, the same reassignment throws an error:
"use strict";
const user = {};
Object.defineProperty(user, "name", {
value: "Alice",
writable: false,
enumerable: true,
configurable: true
});
user.name = "Bob"; // TypeError: Cannot assign to read only property 'name'
When you create a property using Object.defineProperty() and omit flags, they default to false, not true. This is the opposite of what happens with normal property assignment.
This is a critical distinction. Compare:
// Normal assignment (all flags default to true)
const obj1 = {};
obj1.name = "Alice";
console.log(Object.getOwnPropertyDescriptor(obj1, "name"));
// { value: "Alice", writable: true, enumerable: true, configurable: true }
// defineProperty (omitted flags default to false)
const obj2 = {};
Object.defineProperty(obj2, "name", { value: "Alice" });
console.log(Object.getOwnPropertyDescriptor(obj2, "name"));
// { value: "Alice", writable: false, enumerable: false, configurable: false }
Creating a Non-Enumerable Property
A non-enumerable property is hidden from loops and Object.keys(), but it is still accessible directly.
const user = {
name: "Alice",
age: 30
};
Object.defineProperty(user, "id", {
value: 12345,
enumerable: false,
writable: false,
configurable: false
});
console.log(user.id); // 12345 (direct access works)
console.log(Object.keys(user)); // ["name", "age"] ()"id" is hidden)
for (let key in user) {
console.log(key); // "name", "age" (no "id")
}
console.log(JSON.stringify(user)); // '{"name":"Alice","age":30}' (no "id")
This is exactly how built-in methods like toString work on built-in prototypes. They exist but do not show up in enumeration.
Making a Property Non-Configurable
Once configurable is set to false, you cannot:
- Change
enumerable - Change
configurable(back totrue) - Delete the property
- Change it from a data property to an accessor property (or vice versa)
The only thing you can still do is change writable from true to false (but not the reverse).
const user = {};
Object.defineProperty(user, "name", {
value: "Alice",
writable: true,
enumerable: true,
configurable: false
});
// Attempting to reconfigure throws an error
try {
Object.defineProperty(user, "name", { enumerable: false });
} catch (e) {
console.log(e.message);
// TypeError: Cannot redefine property: name
}
// Attempting to delete silently fails (or throws in strict mode)
delete user.name;
console.log(user.name); // "Alice" (still there)
// But we CAN still change writable from true to false
Object.defineProperty(user, "name", { writable: false });
console.log(Object.getOwnPropertyDescriptor(user, "name").writable); // false
// Now we can't change writable back to true
try {
Object.defineProperty(user, "name", { writable: true });
} catch (e) {
console.log(e.message);
// TypeError: Cannot redefine property: name
}
Real-World Example: Making Math.PI Unmodifiable
Built-in constants like Math.PI use these flags internally:
const descriptor = Object.getOwnPropertyDescriptor(Math, "PI");
console.log(descriptor);
// { value: 3.141592653589793, writable: false, enumerable: false, configurable: false }
That is why you cannot change, delete, or reconfigure Math.PI.
Defining Multiple Properties with Object.defineProperties()
When you need to set flags on multiple properties at once, use Object.defineProperties().
const user = {};
Object.defineProperties(user, {
name: {
value: "Alice",
writable: true,
enumerable: true,
configurable: true
},
age: {
value: 30,
writable: false,
enumerable: true,
configurable: false
},
_id: {
value: "usr_001",
writable: false,
enumerable: false,
configurable: false
}
});
console.log(user.name); // "Alice"
console.log(user.age); // 30
console.log(user._id); // "usr_001"
console.log(Object.keys(user)); // ["name", "age"] (_id is non-enumerable)
This is cleaner and more performant than calling Object.defineProperty() multiple times.
Complete Cloning with Object.getOwnPropertyDescriptors()
The method Object.getOwnPropertyDescriptors(obj) returns all property descriptors of an object at once, including non-enumerable and Symbol properties.
const user = { name: "Alice" };
Object.defineProperty(user, "id", {
value: 1,
enumerable: false,
writable: false,
configurable: false
});
console.log(Object.getOwnPropertyDescriptors(user));
Output:
{
name: {
value: "Alice",
writable: true,
enumerable: true,
configurable: true
},
id: {
value: 1,
writable: false,
enumerable: false,
configurable: false
}
}
Why This Matters for Cloning
The common clone approaches like Object.assign() or the spread operator { ...obj } only copy enumerable, own properties and ignore flags entirely. They also skip getters/setters, converting them to plain values.
The problem:
const original = {};
Object.defineProperty(original, "secret", {
value: 42,
enumerable: false,
writable: false
});
// Spread clone loses the non-enumerable property entirely
const clone1 = { ...original };
console.log(clone1.secret); // undefined (lost!)
// Object.assign also misses it
const clone2 = Object.assign({}, original);
console.log(clone2.secret); // undefined (lost!)
The solution: flags-aware cloning:
const original = {};
Object.defineProperty(original, "secret", {
value: 42,
enumerable: false,
writable: false,
configurable: false
});
// Clone with full descriptor preservation
const clone = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(original)
);
console.log(clone.secret); // 42
console.log(Object.getOwnPropertyDescriptor(clone, "secret"));
// { value: 42, writable: false, enumerable: false, configurable: false }
To also preserve the prototype during cloning, combine this with Object.create():
const clone = Object.create(
Object.getPrototypeOf(original),
Object.getOwnPropertyDescriptors(original)
);
Sealing Objects: preventExtensions, seal, and freeze
JavaScript provides three levels of object protection, each more restrictive than the last. These are whole-object operations that affect all properties at once.
Object.preventExtensions(obj)
Prevents adding new properties to the object. Existing properties can still be modified or deleted.
const user = { name: "Alice" };
Object.preventExtensions(user);
user.age = 30; // Silent failure (or TypeError in strict mode)
console.log(user.age); // undefined
// Existing properties are still modifiable
user.name = "Bob";
console.log(user.name); // "Bob"
// Existing properties can still be deleted
delete user.name;
console.log(user.name); // undefined
// Check if object is extensible
console.log(Object.isExtensible(user)); // false
Object.seal(obj)
Prevents adding and deleting properties, and sets configurable: false on all existing properties. Values can still be changed if writable is true.
const user = { name: "Alice", age: 30 };
Object.seal(user);
// Cannot add new properties
user.email = "alice@test.com"; // Silent failure
console.log(user.email); // undefined
// Cannot delete properties
delete user.age; // Silent failure
console.log(user.age); // 30
// CAN still modify existing writable properties
user.name = "Bob";
console.log(user.name); // "Bob"
// Check if object is sealed
console.log(Object.isSealed(user)); // true
Under the hood, Object.seal() is equivalent to calling Object.preventExtensions() and then setting configurable: false on every existing property.
Object.freeze(obj)
The most restrictive level. Prevents adding, deleting, and modifying properties. Sets writable: false and configurable: false on all existing data properties.
const user = { name: "Alice", age: 30 };
Object.freeze(user);
// Cannot add
user.email = "alice@test.com";
console.log(user.email); // undefined
// Cannot delete
delete user.name;
console.log(user.name); // "Alice"
// Cannot modify
user.name = "Bob";
console.log(user.name); // "Alice"
// Check if object is frozen
console.log(Object.isFrozen(user)); // true
Comparison Table
| Feature | preventExtensions | seal | freeze |
|---|---|---|---|
| Add new properties | No | No | No |
| Delete properties | Yes | No | No |
| Modify values | Yes | Yes | No |
| Reconfigure properties | Yes | No | No |
isExtensible() | false | false | false |
isSealed() | Only if no properties | true | true |
isFrozen() | Only if no properties | Only if all non-writable | true |
All three methods return the same object that was passed in. They modify the object in place, they do not create a copy.
Deep Freezing: Implementing Recursive Freeze
There is a critical limitation with Object.freeze(): it only freezes the top level of the object. Nested objects are not affected. This is called a shallow freeze.
The Problem
const config = {
appName: "MyApp",
database: {
host: "localhost",
port: 5432
}
};
Object.freeze(config);
// Top-level property (frozen, cannot change)
config.appName = "NewApp";
console.log(config.appName); // "MyApp" (unchanged, good)
// Nested object (NOT frozen!)
config.database.port = 9999;
console.log(config.database.port); // 9999 (changed! Bad!)
Object.freeze() made config.database a read-only reference (you cannot point it to a different object), but the object it points to is still fully mutable.
The Solution: Recursive Deep Freeze
function deepFreeze(obj) {
// Freeze the object itself
Object.freeze(obj);
// Iterate over all own property values
for (const key of Object.getOwnPropertyNames(obj)) {
const value = obj[key];
// If the value is an object (or function) and not already frozen, recurse
if (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
!Object.isFrozen(value)
) {
deepFreeze(value);
}
}
return obj;
}
Now test it:
const config = {
appName: "MyApp",
database: {
host: "localhost",
port: 5432,
credentials: {
user: "admin",
password: "secret"
}
},
features: ["auth", "logging"]
};
deepFreeze(config);
// Top level is frozen
config.appName = "Changed";
console.log(config.appName); // "MyApp"
// Nested object is frozen
config.database.port = 9999;
console.log(config.database.port); // 5432
// Deeply nested object is frozen
config.database.credentials.password = "hacked";
console.log(config.database.credentials.password); // "secret"
// Arrays inside are frozen too
config.features.push("newFeature"); // TypeError: Cannot add property 2
Be careful with circular references. The !Object.isFrozen(value) check prevents infinite loops, since once an object is frozen, the recursion skips it. However, for highly complex structures, you may want to use a WeakSet to track already-visited objects:
function deepFreeze(obj, visited = new WeakSet()) {
if (visited.has(obj)) return obj;
visited.add(obj);
Object.freeze(obj);
for (const key of Object.getOwnPropertyNames(obj)) {
const value = obj[key];
if (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
!Object.isFrozen(value)
) {
deepFreeze(value, visited);
}
}
return obj;
}
Including Symbol Properties in Deep Freeze
The basic version above uses Object.getOwnPropertyNames(), which only returns string keys. To also freeze properties keyed by Symbols, use Reflect.ownKeys():
function deepFreeze(obj, visited = new WeakSet()) {
if (visited.has(obj)) return obj;
visited.add(obj);
Object.freeze(obj);
for (const key of Reflect.ownKeys(obj)) {
const value = obj[key];
if (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
!Object.isFrozen(value)
) {
deepFreeze(value, visited);
}
}
return obj;
}
Exercise: How to Create a Truly Immutable Object
Let us put everything together. The goal: create an immutable configuration object where no property at any level can be added, removed, or changed.
Requirements
- All properties (including nested) must be non-writable
- All properties must be non-configurable
- No new properties can be added at any level
- Non-enumerable properties should be protected too
- Modifications should throw errors (strict mode behavior)
Step-by-Step Implementation
"use strict";
function createImmutableConfig(config) {
// Deep freeze the entire structure
function deepFreeze(obj, visited = new WeakSet()) {
if (visited.has(obj)) return obj;
visited.add(obj);
Object.freeze(obj);
for (const key of Reflect.ownKeys(obj)) {
const value = obj[key];
if (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
!Object.isFrozen(value)
) {
deepFreeze(value, visited);
}
}
return obj;
}
return deepFreeze(config);
}
// Create the configuration
const appConfig = createImmutableConfig({
version: "2.1.0",
api: {
baseUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
headers: {
"Content-Type": "application/json"
}
},
features: {
darkMode: true,
notifications: true,
experimental: {
aiAssistant: false
}
},
allowedRoles: ["admin", "editor", "viewer"]
});
// Test immutability at every level
try {
appConfig.version = "3.0.0";
} catch (e) {
console.log("Cannot modify version:", e.message);
}
try {
appConfig.api.timeout = 10000;
} catch (e) {
console.log("Cannot modify nested:", e.message);
}
try {
appConfig.api.headers["Authorization"] = "Bearer token";
} catch (e) {
console.log("Cannot add to nested:", e.message);
}
try {
appConfig.features.experimental.aiAssistant = true;
} catch (e) {
console.log("Cannot modify deeply nested:", e.message);
}
try {
appConfig.allowedRoles.push("superadmin");
} catch (e) {
console.log("Cannot modify array:", e.message);
}
try {
delete appConfig.api;
} catch (e) {
console.log("Cannot delete:", e.message);
}
// Reading still works perfectly
console.log(appConfig.api.baseUrl); // "https://api.example.com"
console.log(appConfig.allowedRoles); // ["admin", "editor", "viewer"]
Output:
Cannot modify version: Cannot assign to read only property 'version' of object '#<Object>'
Cannot modify nested: Cannot assign to read only property 'timeout' of object '#<Object>'
Cannot add to nested: Cannot add property Authorization, object is not extensible
Cannot modify deeply nested: Cannot assign to read only property 'aiAssistant' of object '#<Object>'
Cannot modify array: Cannot add property 3, object is not extensible
Cannot delete: Cannot delete property 'api' of #<Object>
https://api.example.com
admin,editor,viewer
Every mutation attempt throws a TypeError in strict mode, while reading remains fully functional.
Verification Helper
You can verify the immutability status of every nested object:
function verifyDeepFrozen(obj, path = "root") {
if (typeof obj !== "object" || obj === null) return;
if (!Object.isFrozen(obj)) {
console.log(`NOT frozen: ${path}`);
return;
}
console.log(`Frozen: ${path}`);
for (const key of Reflect.ownKeys(obj)) {
const value = obj[key];
if (typeof value === "object" && value !== null) {
verifyDeepFrozen(value, `${path}.${String(key)}`);
}
}
}
verifyDeepFrozen(appConfig);
Output:
Frozen: root
Frozen: root.api
Frozen: root.api.headers
Frozen: root.features
Frozen: root.features.experimental
Frozen: root.allowedRoles
In production code, libraries like Immer provide a more ergonomic way to work with immutable data. Instead of freezing objects and preventing mutations, Immer lets you write mutation-like code that produces new immutable copies, which is especially useful in state management (React, Redux).
Summary
Property descriptors give you precise control over how individual properties behave. Here is a quick reference of everything covered:
| API | Purpose |
|---|---|
Object.getOwnPropertyDescriptor(obj, prop) | Read flags for one property |
Object.getOwnPropertyDescriptors(obj) | Read flags for all properties |
Object.defineProperty(obj, prop, descriptor) | Set flags for one property |
Object.defineProperties(obj, descriptors) | Set flags for multiple properties |
Object.preventExtensions(obj) | Block new properties |
Object.seal(obj) | Block new/delete, set configurable: false |
Object.freeze(obj) | Block all changes (shallow) |
Object.isExtensible(obj) | Check if extensible |
Object.isSealed(obj) | Check if sealed |
Object.isFrozen(obj) | Check if frozen |
Key takeaways to remember:
- Properties created with
Object.defineProperty()have flags defaulting tofalse, while normal assignment defaults totrue - Setting
configurable: falseis irreversible Object.freeze()is shallow: nested objects remain mutable unless you implement deep freezing- Use
Reflect.ownKeys()when you need to include Symbol-keyed properties - In strict mode, violations throw
TypeError; in non-strict mode, they fail silently