AlgoInsight

Free lesson · JavaScript Basic to Advance

Property Descriptors and Object.freeze

Easy5 min read

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:

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:

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.freeze is 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 the configurable flag. 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.freeze actually do? Is it deep?" tests understanding of the shallow nature of freeze. "How would you make a true constant?" leads to a discussion of defineProperty with writable: false and configurable: 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 with enumerable: false.

Topic 7.8 — Interview Lens: Objects and Prototypes — covers the deeper interview treatment.

Try It

Exercise: Build a createConstants factory 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.

Back to JavaScript Basic to Advance