Learn JS Series (#47) - Object Immutability: freeze, seal, preventExtensions
Learn JS Series (#47) - Object Immutability: freeze, seal, preventExtensions
What will I learn
- You will learn three levels of locking an object:
preventExtensions,seal, andfreeze; - exactly what each one forbids and allows, and why they form a ladder;
- why
Object.freezeis shallow, and how to freeze deeply with a small recursive helper; - how to check an object's locked state with
isFrozen,isSealed, andisExtensible; - why forbidden changes fail silently in sloppy mode but throw in strict mode;
- when enforced immutability actually helps, and when a discipline of not-mutating is the lighter, better choice.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-46 read, especially property descriptors (ep32) and last time's shallow-vs-deep copy (ep46).
Difficulty
- Intermediate
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
- Learn JS Series (#29) - Memoization: Trading Memory for Speed with Closures
- Learn JS Series (#30) - Mini Project: A Small Functional Utility Library
- Learn JS Series (#31) - Object Literals, Shorthand, and Computed Property Names
- Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable
- Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
- Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
- Learn JS Series (#35) - The Prototype Chain: How Property Lookup Really Works
- Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance
- Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step
- Learn JS Series (#38) - The class Syntax: Sugar Over Prototypes, and What It Hides
- Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again
- Learn JS Series (#40) - Static Members, Static Blocks, and Class-Level State
- Learn JS Series (#41) - Private Fields (#) and True Encapsulation
- Learn JS Series (#42) - instanceof, isPrototypeOf, and Checking Types Honestly
- Learn JS Series (#43) - Mixins: Sharing Behavior Without a Single Inheritance Line
- Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes
- Learn JS Series (#45) - Object Methods: assign, keys, values, entries, fromEntries
- Learn JS Series (#46) - Shallow vs Deep Copy, and structuredClone
- Learn JS Series (#47) - Object Immutability: freeze, seal, preventExtensions (this post)
Learn JS Series (#47) - Object Immutability: freeze, seal, preventExtensions
Last time we spent the whole episode learning how to copy an object so that mutating the copy would not disturb the original. That is one way to be safe around shared data: make a fresh copy and mutate that. Today we come at the same problem from the exact opposite direction. Instead of copying an object to avoid mutating it, we are going to forbid the mutation outright -- reach into JavaScript's toolbox and lock the object down so nobody, not even us by accident, can change it. There are three built-in tools for this, they form a neat little ladder from mild to strict, and -- surprise, surprise -- they carry the very same shallow-versus-deep gotcha we just wrestled with last episode. So this is a natural continuation, and by the end you will know not just how to lock an object, but the far more important question of when you actually should. Grab a console and let's lock some things down. ;-)
Solutions to Episode 46 Exercises
Exercise 1 - the shallow leak:
const orig = { count: 1, list: [1, 2] };
const copy = { ...orig };
copy.count = 99; // top-level primitive: independent
copy.list.push(3); // nested array: SHARED
console.log(orig.count, orig.list); // 1 [1, 2, 3] - only the nested change leaked
The insight: the primitive count was truly copied, so reassigning it on copy left the original alone. But list was a shared reference, so pushing to it reached straight through into the one and only array both objects point at.
Exercise 2 - deep clone and a function:
const data = { nested: { x: 1 } };
const clone = structuredClone(data);
clone.nested.x = 99;
console.log(data.nested.x); // 1 - fully independent
// structuredClone({ fn: () => 1 }); // throws: DataCloneError, cannot clone a function
The insight: structuredClone copies every level of plain data, so mutating the clone can never touch the original -- but it refuses to clone functions and throws a clear DataCloneError rather than silently dropping them.
Exercise 3 - immutable nested update:
const state = { user: { name: "scipio", prefs: { theme: "dark" } } };
const next = { ...state, user: { ...state.user, prefs: { ...state.user.prefs, theme: "light" } } };
console.log(next.user.prefs.theme, state.user.prefs.theme); // "light" "dark"
The insight: copying along the changed path leaves the original intact, and unchanged branches are safely shared because we never mutate them -- we only ever build new objects on top.
Now the other side of the coin: instead of copying to avoid mutation, actually forbid mutation. This is where freeze, seal, and preventExtensions earn their keep.
Three levels of locking
JavaScript gives you three built-in methods to restrict how an object can change, and they form a ladder from least to most strict. Each one includes everything the one below it does, and adds a little more:
Object.preventExtensions(obj): no new properties can be added, but existing ones can still be changed or deleted.Object.seal(obj): no new properties, and none can be deleted, but the values of existing properties can still change.Object.freeze(obj): no new properties, none deleted, and no existing values changed either. Fully locked.
Think of it as three separate locks on three separate things. Prevent-extensions locks the shape from growing (you cannot add keys). Seal locks the shape entirely (you cannot add or remove keys, but you can still edit values). Freeze locks everything (shape and values both). One quick way to remember the order: each level takes away one more power. Prevent-extensions takes away "add". Seal additionally takes away "delete". Freeze additionally takes away "reassign". Let's walk each one with real code, because the differences are subtle and the only way they stick is to see them fail.
preventExtensions: no new properties
Object.preventExtensions is the mildest lock. It stops you adding brand-new properties, while leaving every existing property fully mutable and even deletable:
const obj = { a: 1 };
Object.preventExtensions(obj);
obj.a = 2; // allowed: existing property can change
obj.b = 3; // FORBIDDEN: cannot add a new property (silently fails, throws in strict mode)
console.log(obj); // { a: 2 } - the change stuck, the addition did not
console.log(Object.isExtensible(obj)); // false
delete obj.a; // allowed: existing property CAN still be deleted
console.log(obj); // {} - a is gone, and we still cannot add it back
obj.a = 9; // FORBIDDEN again: once deleted, it is a "new" property
console.log(obj); // {}
Notice the sneaky detail on that last part: once you delete a, adding it back counts as adding a new property, which is forbidden. So prevent-extensions plus a delete can leave you with a shape you can never rebuild. That is exactly why this lock is rarely used on its own -- it only prevents growth, not shrinkage or editing, which is an odd combination to want in isolation. It exists mostly as the foundation the other two build on. When you seal or freeze an object, non-extensibility is part of what you get, plus more.
seal: fixed shape, mutable values
Object.seal does everything preventExtensions does, and additionally prevents deleting properties and reconfiguring them. The result is an object whose shape (its set of keys) is frozen solid, but whose values you can still edit freely:
const config = { theme: "dark", fontSize: 14 };
Object.seal(config);
config.theme = "light"; // allowed: change an existing value
config.newKey = 1; // FORBIDDEN: cannot add
delete config.theme; // FORBIDDEN: cannot delete
console.log(config); // { theme: 'light', fontSize: 14 }
console.log(Object.isSealed(config)); // true
Sealing is genuinely useful when you want to guarantee an object always has exactly a certain set of properties -- no more, no fewer -- while still allowing their values to be updated over time. A settings object is the classic example: you know the full set of valid keys up front, you never want a typo like config.fontSze = 12 to silently create a junk property, but you absolutely do want to change fontSize as the user adjusts it. Seal gives you precisely that: a fixed schema with mutable contents.
Under the hood, and this connects straight back to property descriptors from episode 32, sealing walks every own property and sets its configurable flag to false, then marks the whole object non-extensible. The configurable: false part is what blocks deletion and reconfiguration; the non-extensible part is what blocks additions. Values stay editable because seal leaves each property's writable flag untouched. Hold that thought, because freeze is going to change exactly that one flag.
freeze: fully locked
Object.freeze is the strongest of the three. It does everything seal does and flips every existing property to read-only by setting writable: false. After freezing, the object cannot be changed in any way at all -- no adding, no deleting, no reassigning:
const constants = { PI: 3.14159, E: 2.71828 };
Object.freeze(constants);
constants.PI = 3; // FORBIDDEN: read-only (silently fails, throws in strict mode)
constants.TAU = 6.28; // FORBIDDEN: cannot add
delete constants.E; // FORBIDDEN: cannot delete
console.log(constants); // { PI: 3.14159, E: 2.71828 } - completely unchanged
console.log(Object.isFrozen(constants)); // true
A frozen object is genuinely immutable at the top level. This is the right tool for true constants-as-objects, for configuration that must never change after startup, for enum-like value sets, and for shared data you want to hand out with a hard guarantee that nobody can corrupt it. If you have ever exported an object of constants from a module and worried that some far-away code might scribble on it, freeze is your answer.
The silent-failure trap and strict mode
There is one behaviour of all three locks that trips people up constantly, so let's give it its own moment. When you attempt a forbidden change -- writing to a frozen property, adding to a non-extensible object -- what happens depends on whether you are in strict mode. Outside strict mode (so-called "sloppy mode"), the forbidden operation silently does nothing. No error, no warning, it just quietly evaporates, and your next line runs as if all is well. That is a fantastic way to lose quit some hours debugging why an assignment "isn't working".
"use strict";
const frozen = Object.freeze({ x: 1 });
frozen.x = 2; // in strict mode: throws TypeError, "Cannot assign to read only property 'x'"
// without "use strict", the exact same line would silently do nothing and NOT throw
Under "use strict" the same forbidden assignment throws a TypeError, which is almost always what you want: loud, immediate failure beats a silent no-op that hides a bug. And here is the good news for modern code -- ES modules and the bodies of class declarations are always in strict mode automatically, whether you write the directive or not. So if you are writing modern JavaScript (import/export files, classes), you already get the loud version for free. The silent trap mainly bites in old-style scripts and REPL snippets. Worth knowing so it never surprises you.
freeze is shallow
Here is the crucial catch, and it will feel familiar because it is the exact same lesson as last episode's shallow copy. Object.freeze is shallow: it locks the object's own properties, but if a property holds a nested object or array, that nested object is not frozen and can still be mutated freely:
const settings = {
theme: "dark",
layout: { columns: 3 }, // nested object
};
Object.freeze(settings);
settings.theme = "light"; // blocked (top-level, frozen)
settings.layout.columns = 5; // ALLOWED! the nested object is NOT frozen
settings.layout = {}; // blocked (reassigning the top-level 'layout' slot IS frozen)
console.log(settings.layout.columns); // 5 - mutation slipped through the nesting
Read that carefully, because the distinction is precise. Freezing settings protected the top-level slots: you cannot reassign settings.theme, and you cannot reassign settings.layout to point at a different object. But settings.layout still points at an ordinary, unfrozen object, and reaching into that object to set layout.columns is completely allowed. The freeze locked the box, not the boxes inside the box.
To truly lock a nested structure, you must deep freeze: recursively freeze every nested object, all the way down. A small recursive helper -- and here is recursion from episode 24 paying off again -- does the job cleanly:
function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value && typeof value === "object") deepFreeze(value); // recurse into nested first
}
return Object.freeze(obj); // then freeze this level
}
const locked = deepFreeze({ theme: "dark", layout: { columns: 3, colors: ["red"] } });
locked.layout.columns = 5; // now blocked at every level
locked.layout.colors.push("blue"); // also blocked - arrays are objects too
console.log(locked.layout.columns); // 3 - deeply immutable
console.log(locked.layout.colors); // ["red"] - the push did nothing
deepFreeze walks into every nested object and freezes from the bottom up, giving genuine end-to-end immutability. Note that arrays count as objects here (typeof [] === "object"), so nested arrays get frozen too, which is exactly what you want. One honest caveat: on a huge, deeply nested structure this recursion visits every single node, so deep-freezing a megabyte of data is not free -- something to keep in mind before you reach for it reflexively. This shallow-versus-deep split is turning into a running theme of the series: copies are shallow, freezes are shallow, and reaching all the way to the bottom always takes recursion.
Checking the state
Three companion predicates report an object's locked status, mirroring the three lock methods one-to-one:
const obj = Object.freeze({ x: 1 });
console.log(Object.isFrozen(obj)); // true
console.log(Object.isSealed(obj)); // true (frozen implies sealed)
console.log(Object.isExtensible(obj)); // false (frozen implies non-extensible)
const plain = {};
console.log(Object.isFrozen(plain)); // false
console.log(Object.isExtensible(plain)); // true - a normal object can grow
Note the implications baked into these checks. A frozen object is also reported as sealed and as non-extensible, because freeze is the strictest lock and therefore satisfies the weaker ones automatically. One neat edge case: an empty frozen object and an empty sealed object with no properties behave almost the same, since with zero properties there is nothing to write or delete anyway. These predicates are handy when you want to verify or branch on whether an object is protected before you try to work with it -- for instance, a defensive function that clones an argument only if it is not already frozen.
A look sideways: how Python handles this
Since many of you came here from the Learn Python Series, it is worth a quick comparison, because Python approaches the same goal from a completely different angle -- and seeing the contrast sharpens what JavaScript is actually doing. Python has no direct Object.freeze for arbitrary dicts. Instead it reaches for different types or language features to get immutability:
# A tuple is an immutable sequence (like a frozen array):
point = (3, 4)
# point[0] = 9 # raises TypeError: 'tuple' object does not support item assignment
# frozenset is an immutable set:
tags = frozenset({"admin", "dev"})
# tags.add("root") # raises AttributeError: 'frozenset' has no attribute 'add'
# For objects, dataclasses can be frozen:
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
theme: str
font_size: int
c = Config("dark", 14)
# c.theme = "light" # raises FrozenInstanceError
The key difference: JavaScript's freeze is a runtime operation you apply to any existing object after the fact, and it fails silently unless you are in strict mode. Python bakes immutability into the type up front (tuple, frozenset, frozen dataclass) and raises loudly and always. Neither is "better" -- they are different philosophies. JavaScript treats immutability as an optional lock you snap onto a normal mutable object; Python treats it as a property of a whole category of value. Understanding this makes both languages clearer, and it explains why JavaScript programmers so often lean on discipline rather than freeze, which is the note we will end on.
When to enforce immutability
So should you freeze everything in sight? Almost certainly not, and this is the judgment call that matters more than the syntax. There are two schools of thought, and good code uses both in the right places.
Enforced immutability (actually calling freeze) is valuable for genuine constants, for shared configuration handed across module boundaries, and for defensive APIs where you must guarantee callers cannot mutate the data you gave them -- the runtime stops them, and in strict mode tells them loudly. It is also a great development-time safety net for catching accidental mutations early. The costs: freezing has a small runtime overhead, it is shallow (so protecting deep structures means the expensive deepFreeze walk), and it can genuinely surprise third-party code that expected to mutate what you passed it.
Immutability by discipline -- the approach we leaned on all through Phase 2's pure functions -- simply never mutates in the first place. You always produce new objects with spread instead of editing in place, and you freeze nothing. The immutability is a convention enforced by habit, code review, and the functional patterns we built earlier, rather than by the runtime. This is lighter, has zero runtime cost, and is how the vast majority of modern functional JavaScript actually works. It is also what libraries like Redux assume: the state is treated as immutable by agreement, and you get a new state object on every change.
A solid rule of thumb: freeze your true constants and the public data you hand out and must protect; for your own internal working data, rely on the not-mutating discipline and the immutable-update patterns from last episode rather than freezing everything. Use the hard runtime lock where a guarantee is genuinely worth its cost, and lean on discipline everywhere else. That balance -- knowing which tool the situation calls for -- is the actual skill here, and it is exactly the kind of judgment that separates someone who memorised three method names from someone who understands what they are for.
Try it yourself
- Create an object and apply
Object.sealto it. Show that you can change an existing value but cannot add a new property or delete an existing one. Confirm the locked state withObject.isSealed, and separately confirm withObject.isExtensiblethat it also became non-extensible. - Freeze an object that has a nested object property AND a nested array property. Show that the top-level property is protected but both nested ones can still be mutated. Then write a
deepFreezefunction and show that after deep-freezing, mutations at every level are blocked. - Explain the difference between
Object.preventExtensions,Object.seal, andObject.freezein one sentence each. Then give one concrete situation where you wouldfreezean object, and one where the not-mutating discipline (freezing nothing) is the better choice, and say why for each.
So what did we actually cover?
- Three levels of locking, forming a ladder:
preventExtensions(no new properties),seal(fixed shape, mutable values),freeze(fully read-only). Each includes the one below it and takes away one more power. Object.freezemakes an object immutable at the top level -- the tool for true object-constants, startup config, and protected shared data.- Forbidden changes fail SILENTLY outside strict mode but throw a
TypeErrorin strict mode (and ES modules plus class bodies are always strict), so prefer the loud version. Object.freezeis SHALLOW: nested objects and arrays stay mutable; a recursivedeepFreezeis needed for genuine deep immutability, at the cost of walking the whole structure.Object.isFrozen,isSealed, andisExtensiblereport the state, and frozen implies sealed implies non-extensible.- Freeze true constants and defensive public data; for internal working data, the not-mutating discipline with immutable updates is usually lighter and just as safe. Knowing which to pick is the real skill.
Next episode we close out Phase 3 with a proper mini-project: we will pull prototypes, Object.create, classes, inheritance, mixins, and the immutability ideas from these last two episodes together into one real program -- a small entity system for a tiny game. It is where everything we have built about objects finally clicks into something you can run and play with.