Freezing, sealing & immutability
Lock JavaScript objects with preventExtensions, seal, and freeze; inspect the lock state; write a cycle-safe deepFreeze helper; and separate const from true immutability.
- 01Choose the right lockTry adding, changing, and deleting properties to see what preventExtensions, seal, and freeze each block.
- 02Check how locked an object isUse isExtensible, isSealed, isFrozen, and the property flags to explain what each lock changed.
- 03Lock nested objects tooExplain why freeze only locks the outer object, write a deepFreeze helper, and tell const apart from freeze.
Locking an object
Normally, you can change an object in many ways:
- Add a new property.
- Change the value of a property.
- Delete a property.
- Change a property's flags with
Object.defineProperty.
That freedom is useful while you build an object. But some objects should stay as they are once they are ready. Think of the settings of an app, or a list of user roles that a lot of code reads. If some code changes them by mistake, the bug shows up far away from the line that caused it.
JavaScript has three built-in locks for objects: Object.preventExtensions, Object.seal, and Object.freeze. Each one changes the object you give it. None of them makes a copy.
The locks work through the property flags from the last lesson, Property flags & descriptors. Every property has hidden settings called flags. Two of them matter here:
writable: can the property's value be changed?configurable: can the property be deleted, or its flags be changed?
Think of an object as a building, and its properties as rooms. preventExtensions closes the building to new rooms. The rooms already there stay open. You can redecorate them, and you can even knock some down.
seal also stops anyone knocking rooms down. You can still redecorate. freeze turns the building into a museum exhibit: no new rooms, no knocking down, and no redecorating. A deep freeze does this to every building on the campus, not just one.
- In real life: Close the building to new rooms
- In JavaScript:
Object.preventExtensions(obj) - In real life: Lock the walls against demolition
- In JavaScript:
Object.seal(obj) - In real life: Turn the building into a museum exhibit
- In JavaScript:
Object.freeze(obj) - In real life: Freeze every building on campus
- In JavaScript: A
deepFreeze(obj)helper
freeze() is the strongest.What happens when code tries something that a lock blocks? That depends on strict mode. Strict mode is a set of stricter rules. You turn it on by writing "use strict", and it is always on in modules and classes.
- In strict code, a blocked change throws a
TypeError. ATypeErroris the error JavaScript throws when you try something a value doesn't allow. - In code that is not strict, many blocked changes just do nothing. There is no error, so the mistake is easy to miss.
const cart = { items: 1 };
Object.preventExtensions(cart);
cart.newItem = "tea";
console.log(Object.hasOwn(cart, "newItem"));
function strictAdd() {
"use strict";
try {
cart.otherItem = "coffee";
} catch (error) {
console.log(error.name);
}
}
strictAdd();Line 2 locks cart, so it can't get new properties. Line 4 tries to add newItem anyway. This line is not in strict mode, so nothing happens and there is no error.
Line 5 asks whether cart now has its own newItem property. Object.hasOwn answers that question, and it prints false.
The function strictAdd turns on strict mode on line 8. So the add on line 10 throws a TypeError. The catch on line 11 catches it, and line 12 prints its name, TypeError.
That is why strict mode helps when you test locked objects. A blocked change shows up as an error instead of hiding.
preventExtensions: no new properties
INTERACTIVEObject.preventExtensions(obj) is the weakest lock. It does one thing: it stops new own properties from being added. An own property is one stored on the object itself. An object that can't get new properties is called non-extensible.
Properties that already exist keep their flags. So:
- If a property is writable, you can still change its value.
- If a property is configurable, you can still delete it.
Use preventExtensions when an object should stop growing, but its current properties should still work as before.
const settings = { theme: "dark", retries: 2, nested: { count: 1 } };// no locksettings.retries = 3;settings.newFlag = true;delete settings.theme;settings.nested.count = 2;Object.defineProperty(settings, "retries", { value: 4 });- In strict mode
- not run yet
- Without strict mode
- added newFlag
- The three checks
- isExtensible: true · isSealed: false · isFrozen: false
- Flags of
theme - writable: true · configurable: true
Pick a lock and an action, then press Run. The strict result is worked out when you press Run. The result without strict mode is for the same action, checked by the lesson's tests.
Pick preventExtensions and try each action. Here is what happens:
- add property is blocked. This is the one thing the lock stops.
- change value works:
retriesbecomes 3. - delete property works. Properties written inside
{ }start out configurable, sothemecan be deleted. - change nested value works.
settings.nestedholds a separate object, and the lock only coverssettingsitself. - redefine value works. It changes
retrieswithObject.defineProperty, andretriesis still configurable and writable.
The same blocked add gives a different signal in each kind of code. Strict code throws an error, so you notice the bug. Code that is not strict often leaves the object as it was and keeps going. Modules are always strict, but you may still read older scripts that are not.
seal: no adding, no deleting
STEP THROUGHObject.seal(obj) does everything preventExtensions does. Then it sets the configurable flag of every own property to false. After that:
- You can't add new properties.
- You can't delete properties.
- You can't change a property's flags.
- You can still change a value, if the property is writable.
So a sealed settings object still allows settings.retries = 3. But it blocks delete settings.theme.
Object.seal(settings), you can still change a value, so retries becomes 3. You can't delete a property, so theme stays.Step through and watch the flags. seal sets configurable to false. freeze also sets writable to false.
script
Object.seal(ticket);const sealed = Object.getOwnPropertyDescriptors(ticket); const badge = { name: "Ada", score: 10 };Object.freeze(badge);const frozen = Object.getOwnPropertyDescriptors(badge);Step through the code. It uses Object.getOwnPropertyDescriptors(obj), which returns the flags of every own property. Watch two flags:
sealchangesconfigurabletofalse.sealleaveswritableas it was.
One more detail. An accessor property is a property that runs a getter or a setter function, from the Getters & setters lesson. It has no writable flag. So seal and freeze only stop it from being deleted or redefined. They don't change what its getter or setter does.
freeze: no changes at all
SORTObject.freeze(obj) is the strongest built-in lock. It does three things:
- It stops new properties, like
preventExtensions. - It sets
configurabletofalseon every own property, likeseal. - It sets
writabletofalseon every data property. A data property is one that holds a value, not a getter or setter.
So no value on the object can change. In strict code, assigning to a frozen property throws a TypeError.
freeze only covers the object's own properties. A property can hold another object. Then the property can't be pointed at a different object, but that other object can still change. You'll see this in the "Deep freeze" section below.
- Change a writable value after
preventExtensions - Delete a configurable property after
preventExtensions - Change a writable value after
seal - Change
obj.nested.countafter freezing onlyobj - Delete a property of a sealed object
- Give a frozen property a new value
- Add a brand-new property after
preventExtensions - Delete a property of a frozen object
Sort each action into the lock that still allows it. If the lock blocks it, put it in 'Blocked by that lock'. Read the explanation on every card; a few are tricky.
A frozen array can't grow. In strict code, list.push(...) throws a TypeError, because push has to add a new index and change length. Freezing is a common way to protect a fixed list. It is not a way to make code faster.
Checking lock levels
JavaScript has three methods that tell you how locked an object is. Each one returns true or false:
Object.isExtensible(obj): can new properties be added?Object.isSealed(obj): is the object non-extensible, and are all its own properties non-configurable?Object.isFrozen(obj): is the object sealed, and are all its own data properties non-writable too?
const cart = { items: 1 };
console.log(Object.isExtensible(cart), Object.isSealed(cart), Object.isFrozen(cart));
Object.seal(cart);
console.log(Object.isExtensible(cart), Object.isSealed(cart), Object.isFrozen(cart));
Object.freeze(cart);
console.log(Object.isExtensible(cart), Object.isSealed(cart), Object.isFrozen(cart));- Line 2 checks a plain object. It prints
true false false. - Line 3 seals the object. Line 4 prints
false true false. - Line 5 freezes the object. Line 6 prints
false true true.
Each method answers a different question. If you only check isFrozen, you miss the middle state: sealed, but values can still change.
The table below shows what the three checks return after each lock. Read each row as: "after this lock, the checks say this, and existing values can or can't change."
| After this lock | isExtensible? | isSealed? | isFrozen? | Can existing values change? |
|---|---|---|---|---|
| No lock: a plain object | true | false | false | Yes. |
preventExtensions | false | Usually false. | Usually false. | Yes, if the property is writable. |
seal | false | true | Usually false. | Yes, if the property is writable. |
freeze | false | true | true | No, not for data properties. |
preventExtensions on an empty object | false | true | true | There are no properties to change. |
An empty object passed to Object.preventExtensions also counts as sealed and frozen. Why? Sealed means "no configurable own properties", and frozen means "no writable own properties". An empty object has no properties at all, so both checks pass.
To see the exact flags of each property, use Object.getOwnPropertyDescriptors(obj). The three checks give a quick summary. The flags explain why a change, a delete, or a redefine is allowed or blocked.
Deep freeze: lock the nested objects too
INTERACTIVEObject.freeze is shallow. Shallow means it locks only the object you give it, not the objects stored inside it.
In the code below, team.lead holds a second object. After Object.freeze(team), the lead property can't point to a different object. But the lead object itself is not frozen, so its points can still change.
Guess first: does line 6 change points? Then step through to check.
script
name: "UI", lead: { name: "Ada", points: 1 },};Object.freeze(team);team.lead.points = 2;console.log(team.lead.points);console.log(Object.isFrozen(team.lead));Object.freeze(team) locks only the team object. The object that lead points to is a separate object, so it can still change.A deep freeze freezes the object, then every object inside it, then every object inside those, and so on. The helper below does this. It calls itself for each property value. A function that calls itself is called recursive.
The helper uses two tools you may not have seen yet:
Reflect.ownKeys(value)lists every own property name of an object, including symbol keys.- A
WeakSetis a set that holds objects. The helper adds each object to it, so it can tell which objects it has already visited.
Why remember visited objects? An object can point back to itself, like config.self = config. This is called a cycle. Two properties can also share the same nested object. Without the WeakSet, a cycle would make the helper call itself forever.
function deepFreeze(value, seen = new WeakSet()) { if (value === null || typeof value !== "object") return value; if (seen.has(value)) return value; seen.add(value); for (const key of Reflect.ownKeys(value)) { deepFreeze(value[key], seen); } return Object.freeze(value);}- Change the inner object after deepFreeze
- not run yet
- Is it frozen?
- not run yet
- push on a frozen array
- not run yet
- Frozen instance with a #private field
- not run yet
Read the helper first. Line 2 returns simple values like numbers as they are. Line 3 skips objects it has already seen, and line 4 remembers this one. Lines 5 to 7 freeze every property value. Line 8 freezes the object itself.
A deep freeze protects objects from change. It is not the only way to avoid unwanted changes. Later, the Immutability lesson shows another way: make an updated copy instead of changing the original object.
const vs frozen
INTERACTIVEconst and Object.freeze lock different things. const locks the variable: once the name points to a value, it can't be pointed at a different value. It doesn't lock anything inside that value.
A const variable is like a label glued to one building. The label can't be moved to another building. But the label says nothing about the rooms inside, so people can still repaint them. Freezing is what stops the repainting.
- In real life: A label glued to one building
- In JavaScript:
const profile = ...can't be reassigned - In real life: People repaint rooms inside
- In JavaScript: Properties can still change
- In real life: Museum ropes inside the building
- In JavaScript:
Object.freeze(profile)blocks property changes
const and Object.freeze lock two different things. const keeps the name pointing at the same object. freeze keeps the object's properties from changing.The table compares the two. Read each row as a question, then the answer for const and for Object.freeze.
| Question | const | Object.freeze |
|---|---|---|
| What does it lock? | The variable: the name always points to the same value. | The object's own properties. |
Can obj.x = 2 work? | Yes, if the property allows it. | No. In strict code it throws a TypeError. |
Can obj = {} work? | No. A const variable can't be given a new value. | Freeze doesn't decide this. It doesn't control the variable. |
| Does it lock nested objects? | No. It doesn't lock anything inside the object. | No. It is shallow, unless you use a deep freeze. |
const profile = { name: "Ada", score: 1 };profile.score = 2; // allowed: the object changedObject.freeze(profile);profile.score = 3; // blocked in strict code// profile = {}; // TypeError: a const variable can't get a new value- Before freeze
- not run yet
- After freeze
- not run yet
- Object.isFrozen(profile)
- not run yet
Guess first: does const stop line 2? Does freeze stop line 4? Then run the code to see which one locks the variable and which one locks the object.
Where you use it
Locks are most useful where your code hands an object to other code. They turn an accidental change into an error you can see while you build. They also tell other developers that the value is finished and should not change.
- Freeze fixed lookup data, such as a table of keyboard shortcuts or a list of allowed roles.
- Seal an object whose list of properties must stay the same, but whose values still update. One example is an object that counts page visits.
- Use
preventExtensionswhen plugin code should not add new properties to an object you own. Plugin code is code that other people write to add features to your app. - Deep freeze test fixtures. A test fixture is sample data that several tests share. Freezing it means one test can't change the data that the next test uses.
const appConfig = Object.freeze({ apiUrl: "/api", retries: 2 });
const roles = Object.freeze(["reader", "editor"]);
console.log(Object.isFrozen(appConfig), appConfig.retries);
try {
roles.push("admin");
} catch (error) {
console.log(error.name);
}- Line 1 freezes
appConfig. Line 2 freezes therolesarray. - Line 3 prints
true 2. The object is frozen, but you can still read its values. - Line 5 tries
roles.push("admin").pushneeds to add a new index and changelength, so it throws. Line 7 printsTypeError.
Freeze values like these only when they really are fixed. Don't freeze them when other code needs to update them.
Freezing stops changes, but it doesn't give you a way to make an updated version of an object. The Immutability lesson, linked above, shows how to do that with copies.
Don't freeze objects to make code faster. JavaScript engines change over time, and freezing is not a reliable way to gain speed. Use locks to catch mistakes and to show which objects must not change.
Common misconceptions
“Freeze is deep.”
No. Object.freeze is shallow. It locks only the object you give it. Objects stored inside it must be frozen too, usually with a deepFreeze helper that calls itself.
“const freezes objects.”
No. const locks the variable, not the object. You can still change the properties of a const object, unless a lock stops you.
“seal makes values read-only.”
No. On a sealed object, writable properties can still get new values. seal blocks adding, deleting, and changing flags.
“A blocked change always throws.”
Only in strict code. In code that is not strict, a blocked assignment does nothing and gives no error. A blocked delete returns false.
“A frozen class instance can't change at all.”
Private fields, the ones whose names start with #, are not properties of the object. So freeze doesn't lock them. A method can still update a #private field even when Object.isFrozen(instance) is true.
“Freeze makes code faster.”
Use freeze to catch mistakes, not for speed. Don't say it is faster unless you have measured it in your own program.
Practice: lock objects on purpose
5 EXERCISESTrace the code. Type the two lines it prints, in order.
"use strict";
const config = { mode: "dev", retries: 1 };
Object.seal(config);
config.retries = 2;
try { delete config.mode; } catch (error) { console.log(error.name); }
console.log(config.retries, config.mode);The delete on line 5 throws a TypeError, so the catch prints TypeError. Then line 6 prints 2 dev: retries changed to 2, and mode is still dev.
What does this code print?
"use strict";
const config = { nested: { count: 1 } };
Object.freeze(config);
config.nested.count = 2;
console.log(config.nested.count, Object.isFrozen(config.nested));Line 4 changes the nested object, which is not frozen, so count becomes 2. Object.isFrozen(config.nested) is false. So it prints 2 false.
Some code changes a shared config object by mistake. Create the object with deepFreeze. Then check that the nested api object is frozen too.
const config = deepFreeze({
api: { retries: 2 },
});
try { config.api.retries = 9; } catch (error) { console.log(error.name); }
console.log(Object.isFrozen(config.api));const config = deepFreeze({
api: { retries: 2 },
});
console.log(Object.isFrozen(config.api));With Object.freeze alone, config.api.retries could still change. deepFreeze freezes both config and config.api, so the check on config.api prints true.
Fill in the blank: const locks the ___, freeze locks the object.
const locks the variable, also called the binding. The name profile can't be pointed at another value. But const doesn't freeze profile.score.
You froze a config with deepFreeze. Which expression proves that the nested api object is frozen too?
console.log(Object.isFrozen(config));
console.log(Object.isFrozen(config.api));To be sure everything is frozen, check each level you care about. The check for the nested object is Object.isFrozen(config.api).
Quiz: check your understanding
7 QUESTIONSThese questions mix ideas with real code. Read the explanation for every choice, even when you get it right.
Question 1 of 7What does
Object.preventExtensions(obj)block?Choose an answer to see the explanation.
Question 2 of 7What does the sealed counter print?
Read the code, then predict"use strict"; const item = { count: 1 }; Object.seal(item); item.count = 2; console.log(item.count);Choose an answer to see the explanation.
Question 3 of 7Which flags does
Object.freezeset on a property that holds a value?Choose an answer to see the explanation.
Question 4 of 7What does the empty-object example print?
Read the code, then predictconst empty = {}; Object.preventExtensions(empty); console.log(Object.isSealed(empty), Object.isFrozen(empty));Choose an answer to see the explanation.
Question 5 of 7What does this code print after freezing only the outer object?
Read the code, then predictconst box = { nested: { value: 1 } }; Object.freeze(box); box.nested.value = 2; console.log(box.nested.value);Choose an answer to see the explanation.
Question 6 of 7After
const user = { name: "Ada" }, which statement is true?Choose an answer to see the explanation.
Question 7 of 7Why should a
deepFreezehelper keep aWeakSetof objects it has seen?Choose an answer to see the explanation.
Key takeaways
Object.preventExtensionsonly stops new properties from being added.Object.sealalso stops properties from being deleted or having their flags changed. Values can still change.Object.freezealso stops the values of data properties from changing.Object.isExtensible,Object.isSealed, andObject.isFrozentell you how locked an object is.Object.getOwnPropertyDescriptorsshows the exact flags.- Freeze is shallow. A
deepFreezehelper with aWeakSetfreezes the nested objects too, even when an object points back to itself. constlocks the variable, not what is inside the object.
Remember the one-liner.
preventExtensions stops adding; seal also stops deleting; freeze also stops changing values on that object.
Up next: Enumerability & ownership. You'll learn which properties loops and object methods can see.