Mert Özen Further With Every Line
Frontend JavaScript Advanced — #02

JavaScript Object Protection and Immutability: Property Descriptors, seal and freeze

Mert Özen Nov 3, 2026 8 min 6 views
JavaScript Object Protection and Immutability: Property Descriptors, seal and freeze

We look at property descriptors, the sneaky defaults of Object.defineProperty, the difference between preventExtensions, seal and freeze, shallow versus deep freezing, and patterns for updating objects without mutating them.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

We have an object holding an app's settings, and we freeze it so nobody can change it. What do you see when you paste this into the console?

const config = Object.freeze({
  api: "https://example.com",
  retry: { count: 3 }
});

config.api = "http://evil.test";
config.retry.count = 10;

console.log(config.api, config.retry.count);

Make a guess before you keep reading. Will both assignments be blocked, will both go through, or will one pass and the other get stuck? And think about this too: will you get an error, or will everything happen silently?

The Hidden Settings of Every Property

In the previous post we saw how properties are looked up along the chain. Now let's look closely at a single property. When you write a value with user.name = "Ada", the engine actually stores not just the value but three settings along with it. Together they're called a property descriptor:

const user = { name: "Ada" };

console.log(Object.getOwnPropertyDescriptor(user, "name"));
// { value: "Ada", writable: true, enumerable: true, configurable: true }

Think of a property as a labeled drawer. value is what's inside the drawer. writable says whether you can replace the contents. enumerable decides whether the drawer appears on the inventory list; Object.keys, for...in, spread and JSON.stringify all read that list. configurable says whether the drawer can be removed and its label rewritten, that is, whether the property can be deleted and its settings changed.

For every property you create with a plain assignment or an object literal, all three are true. The language's own objects are a different story. Look at the descriptor of Array.prototype.map and you'll see enumerable: false; that's why methods don't show up when you loop over an array with for...in. The class methods we saw earlier being non-enumerable comes from the same setting. If you want to see all of an object's descriptors at once, there's Object.getOwnPropertyDescriptors.

Taking Control with Object.defineProperty

When you want to set those settings yourself, you use Object.defineProperty. Try it in the console:

const product = { name: "Keyboard" };

Object.defineProperty(product, "id", { value: 42 });

console.log(product.id); // 42
console.log(Object.keys(product)); // ["name"]
console.log(JSON.stringify(product)); // {"name":"Keyboard"}

product.id = 99;
console.log(product.id); // 42

delete product.id;
console.log(product.id); // 42

Every setting we didn't write became false. This is the most overlooked rule of defineProperty: with a plain assignment the defaults are true, with defineProperty they're false. The result is that id became a property you can read but that doesn't appear in listings, can't be changed and can't be deleted.

configurable: false is a one-way door. Once you close it, you can't delete that property, can't change its enumerable setting and can't turn it into a getter/setter. The only settings change allowed is flipping writable from true to false; there's no flipping it back. As long as writable stays on, changing the value is of course still fine. To define several properties at once there's Object.defineProperties, and the object we passed as the second argument to Object.create in the previous post was in exactly this format.

Defining Getters and Setters with a Descriptor

There are two kinds of descriptors. The one we've seen so far, carrying value and writable, is a data descriptor. The other is an accessor descriptor: instead of a value it carries get and set functions. Reading runs the getter, writing runs the setter.

const temperature = { celsius: 20 };

Object.defineProperty(temperature, "fahrenheit", {
  get() {
    return this.celsius * 9 / 5 + 32;
  },
  set(value) {
    if (typeof value !== "number") throw new TypeError("Expected a number");
    this.celsius = (value - 32) * 5 / 9;
  },
  enumerable: true
});

console.log(temperature.fahrenheit); // 68
temperature.fahrenheit = 212;
console.log(temperature.celsius); // 100

You can't mix the two kinds: putting both value and get in the same descriptor throws a TypeError. The get fullName() {} syntax we used earlier in classes and object literals produces exactly this kind of accessor descriptor behind the scenes. The difference is that in a class the getter goes on the prototype as non-enumerable, while in a literal it goes on the object itself as enumerable.

Validating inside a setter is the simplest way to expose a value while keeping control over it. In the previous post we mentioned the exceptions to shadowing; here's one: if there's a setter up the chain, an assignment doesn't create a new property, it runs that setter.

Three Locks: preventExtensions, seal and freeze

When you want to lock the whole object instead of tuning properties one by one, there are three levels. Think of a house. Object.preventExtensions forbids adding new rooms, but you can still knock existing rooms down and swap their furniture. Object.seal fixes the walls: no rooms can be added or removed, but the furniture can move around. Object.freeze turns the house into a museum: nothing can be touched.

const open = Object.preventExtensions({ x: 1 });
open.y = 2;
delete open.x;
console.log(open); // {}

const sealed = Object.seal({ x: 1 });
sealed.x = 5;
sealed.y = 2;
delete sealed.x;
console.log(sealed); // { x: 5 }

const frozen = Object.freeze({ x: 1 });
frozen.x = 5;
frozen.y = 2;
delete frozen.x;
console.log(frozen); // { x: 1 }

Technically, seal makes the object non-extensible and sets every property to configurable: false. freeze adds writable: false on top; it leaves getters and setters alone, because they have no writable setting. To query the state there are Object.isExtensible, Object.isSealed and Object.isFrozen. None of the three can be undone: there's no way to unfreeze a frozen object, you can only make a copy.

Silent Failure or TypeError?

You didn't see a single error when you ran the example above in the console, did you? The console runs outside strict mode by default (sloppy mode), and there an assignment to a non-writable property is silently ignored. The code keeps running, and you spend hours wondering why your change didn't stick.

In strict mode the same assignment throws a TypeError. As we saw earlier, modules and class bodies are always in strict mode, so in real projects you'll usually see the error. To see the difference in the console:

(function () {
  "use strict";
  const locked = Object.freeze({ x: 1 });
  try {
    locked.x = 2;
  } catch (error) {
    console.log(error.name); // "TypeError"
    console.log(error.message);
  }
})();

The text of the error message varies from browser to browser, but the type is TypeError everywhere. There's one more exception: calling push on a frozen array throws a TypeError even in sloppy mode, because push treats a failed assignment as an error internally. Type Object.freeze([1, 2]).push(3) and see for yourself.

Shallow Freeze, Deep Freeze

Now let's go back to the opening question. In the console the output is https://example.com 10. The config.api assignment was silently ignored because config is frozen. But config.retry.count = 10 went through. Why? Object.freeze is shallow: it locks only the object's own properties. The config.retry property points to another object; you can't change that reference, but the object it points to isn't frozen. Had you run the same code in strict mode, you'd have gotten a TypeError on the first assignment and never reached the second line.

If you want to freeze the whole tree, you have to walk it yourself:

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

const settings = deepFreeze({
  theme: { colors: { primary: "#0af" } },
  langs: ["tr", "en"]
});

settings.theme.colors.primary = "red";
console.log(settings.theme.colors.primary); // "#0af"
console.log(Object.isFrozen(settings.langs)); // true

Reflect.ownKeys returns all of an object's own keys, both strings and symbols; we'll cover the Reflect API in upcoming posts. Freezing the object first and only then descending into its children is a deliberate choice: if objects point to each other in a cycle, the isFrozen check keeps the function from entering the same object twice and falling into infinite recursion.

Immutable Update Patterns

If you can't change a frozen object, how do you update it? The answer: you don't change it, you produce a new copy. You copy the branch that changes and share the branches that don't:

const state = deepFreeze({
  user: { name: "Ada", city: "Izmir" },
  tags: ["js", "css"],
  settings: { dark: true }
});

const next = {
  ...state,
  user: { ...state.user, city: "Ankara" },
  tags: [...state.tags, "html"]
};

console.log(state.user.city, next.user.city); // "Izmir" "Ankara"
console.log(state.user === next.user); // false
console.log(state.settings === next.settings); // true

You need to have pasted the deepFreeze above into the console before running this. For arrays, the ES2023 methods we saw earlier make life easier: toSorted, toReversed, toSpliced and with return a new array without touching the original. If you want to work freely on a copy of the whole tree there's structuredClone, but copying the entire tree on every update is needlessly expensive.

The real payoff of this pattern: to find out whether something changed, you don't have to look inside it, comparing references is enough. If state.user !== next.user, the user changed; if state.settings === next.settings, the settings are the same. Keeping old states around for an undo feature also comes for free, because none of them ever changes afterwards.

Common Pitfalls

Confusing const with freezing. As we saw earlier, const only stops the variable from being rebound to another value; the object it points to can change as much as it likes.

Assuming everything can be frozen. freeze locks properties, not internal state. Map, Set and Date keep their data in internal slots rather than properties:

const frozenMap = Object.freeze(new Map());
frozenMap.set("a", 1);
console.log(frozenMap.size); // 1

const frozenDate = Object.freeze(new Date(2026, 0, 1));
frozenDate.setFullYear(2000);
console.log(frozenDate.getFullYear()); // 2000

Forgetting that a frozen prototype affects its children. If there's a non-writable property up the chain, assigning a property with the same name on a child object fails too. This is the second shadowing exception from the previous post:

const basePrinter = Object.freeze({ print() { return "base"; } });
const myPrinter = Object.create(basePrinter);

myPrinter.print = () => "custom";
console.log(myPrinter.print()); // "base"

If you really want the child to have its own method, you need Object.defineProperty(myPrinter, "print", { value: ... }) instead of an assignment. And finally, forgetting the defaults of defineProperty: if you only want to make a property read-only but leave out enumerable and configurable, you've accidentally hidden it from listings as well.

A Small Experiment

★☆☆ Predict before running: const p = Object.seal({ a: 1 }); p.a = 2; p.b = 3; delete p.a; console.log(p, Object.isFrozen(p)); Then repeat the experiment with Object.freeze.

★★☆ Add a visits property to an object that goes up by one every time it's read but can't be changed from outside with an assignment. Hint: use a getter, don't write a setter, and keep the counter in a variable outside the object. Make sure it shows up in the Object.keys output too.

★★★ Write the opposite of deepFreeze: thaw(object), which returns a mutable deep copy of a frozen object. Write one version with structuredClone and another with your own recursive function that uses Object.getOwnPropertyDescriptors. Try both on an object that has a getter: what does structuredClone do to the getter, and what does yours do?

We've learned to lock properties and to work with copies. In the next post we take a step back and look at how we design objects in the first place: inheritance or composition, factory functions, mixins and the ways to truly hide data.