---
title: 'Immutability Techniques'
source: 'https://academia.sh/en/courses/javascript-object-model/immutability-techniques'
course: 'Objects and Functions in JavaScript'
language: en
updated: '2026-08-23T07:01:00+00:00'
license: 'CC BY-SA 4.0'
---

# Immutability Techniques

The scope of freezing, sealing, and preventing extensions; the consequences of their staying shallow, deep freezing, and patterns for producing a new object instead of a change.

The previous lesson locked a single property with `writable: false`. Protecting an
entire object requires writing a separate definition for every field, which is tedious
and still does not prevent new fields from being added. The language offers three
operations that work at the object level.

This lesson's subject is **immutability**: an object's content not changing after it is
created. The term should not be confused with the *class invariant* mentioned in the
previous lesson; an invariant is a rule that never changes, immutability is a capability.
The Introduction to Functional Programming lesson in the Programming Fundamentals course
established the reasoning for this capability: an object whose value does not change
holds no surprises no matter how many places reference it.

## Freezing

`Object.freeze` makes all of an object's own properties non-writable and
non-configurable, and also forbids new properties from being added to the object.

```js
const record = Object.freeze({
  sensor: "S-01",
  value: 21.4,
  time: 1000,
});

console.log(Object.isFrozen(record));
console.log(Object.isExtensible(record));

const attempts = [
  ["writing an existing field", () => { record.value = 30; }],
  ["adding a new field", () => { record.unit = "celsius"; }],
  ["deleting a field", () => { delete record.time; }],
  ["changing the definition", () => { Object.defineProperty(record, "value", { value: 30 }); }],
  ["changing the prototype", () => { Object.setPrototypeOf(record, null); }],
];

for (const [name, action] of attempts) {
  try {
    action();
    console.log(`${name}: passed`);
  } catch (error) {
    console.log(`${name}: ${error.constructor.name}`);
  }
}

console.log(JSON.stringify(record));
const descriptor = Object.getOwnPropertyDescriptor(record, "value");
console.log(`writable=${descriptor.writable}, configurable=${descriptor.configurable}`);
```

```
true
false
writing an existing field: TypeError
adding a new field: TypeError
deleting a field: TypeError
changing the definition: TypeError
changing the prototype: TypeError
{"sensor":"S-01","value":21.4,"time":1000}
writable=false, configurable=false
```

All five change paths are closed; even the prototype link is fixed. Because this lesson
runs in a module file — that is, in strict mode — the attempts throw errors. In
non-strict mode, the same operations fail silently — the same distinction as in the
previous lesson.

Freezing cannot be undone. There is no operation like `Object.unfreeze`, because once
configurability is closed, the descriptor can never be changed again. Freezing an object
is a final decision made on that object.

## Freezing Is Shallow

`Object.freeze` touches only the object's **own** properties. Objects inside it and
definitions on the prototype fall outside its scope.

```js
const measurementBehavior = {
  format() {
    return `${this.sensor}: ${this.value}`;
  },
};

const record = Object.create(measurementBehavior);
record.sensor = "S-01";
record.value = 21.4;
record.source = { file: "measurement-2.csv", line: 17 };
Object.freeze(record);

console.log(Object.isFrozen(record));
console.log(Object.isFrozen(record.source));

record.source.line = 99;
console.log(record.source.line);

measurementBehavior.format = function () {
  return `[changed] ${this.sensor}`;
};
console.log(record.format());
console.log(Object.isFrozen(Object.getPrototypeOf(record)));
```

```
true
false
99
[changed] S-01
false
```

There are two leaks. The first is familiar: the record's `source` field is a reference;
what freezes is the reference itself, not the object it points to. This is the same
shallow-copy discussion from the Value and Reference and Passing lesson in the
Programming Fundamentals course.

The second concerns the prototype and is specific to this course. The record's
*prototype link* is frozen, but the *prototype object* is not. Changing a behavior on the
chain changes the frozen object's behavior. If both an object's data and its behavior
need to stay fixed, the prototype object has to be frozen separately.

## The Scope of the Three Operations

Freezing is the strictest option; it has two looser forms. `Object.preventExtensions`
only forbids adding new properties; `Object.seal` adds deletion to that as well. Running
all three through the same tests is enough to see the difference.

```js
function check(name, obj) {
  const results = [];
  try {
    obj.value = 30;
    results.push(`write=${obj.value === 30 ? "yes" : "no"}`);
  } catch {
    results.push("write=error");
  }
  try {
    obj.unit = "celsius";
    results.push(`add=${Object.hasOwn(obj, "unit") ? "yes" : "no"}`);
  } catch {
    results.push("add=error");
  }
  try {
    delete obj.sensor;
    results.push(`delete=${Object.hasOwn(obj, "sensor") ? "no" : "yes"}`);
  } catch {
    results.push("delete=error");
  }
  console.log(`${name}: ${results.join(", ")}`);
}

check("open           ", { sensor: "S-01", value: 21.4 });
check("non-extensible ", Object.preventExtensions({ sensor: "S-01", value: 21.4 }));
check("sealed         ", Object.seal({ sensor: "S-01", value: 21.4 }));
check("frozen         ", Object.freeze({ sensor: "S-01", value: 21.4 }));
```

```
open           : write=yes, add=yes, delete=yes
non-extensible : write=yes, add=error, delete=yes
sealed         : write=yes, add=error, delete=error
frozen         : write=error, add=error, delete=error
```

The scopes are nested: preventing extensions ⊂ sealing ⊂ freezing. Sealing suits objects
whose field set is fixed but whose values can still change — like locking a
configuration record's shape while leaving its content free. Freezing makes the object
fixed in its entirety.

## Deep Freezing

Getting past shallowness requires a recursive walk. The pattern from the Recursion lesson
in the Programming Fundamentals course applies directly: the base case is non-object
values, the reduction step is freezing each field itself. To keep circular references
from causing infinite recursion, visited objects are marked.

```js
function deepFreeze(obj, seen = new WeakSet()) {
  if (obj === null || typeof obj !== "object" || seen.has(obj)) {
    return obj;
  }
  seen.add(obj);
  for (const name of Object.getOwnPropertyNames(obj)) {
    const descriptor = Object.getOwnPropertyDescriptor(obj, name);
    if ("value" in descriptor) deepFreeze(descriptor.value, seen);
  }
  return Object.freeze(obj);
}

const record = deepFreeze({
  sensor: "S-01",
  value: 21.4,
  source: { file: "measurement-2.csv", line: 17 },
  tags: ["raw", "temperature"],
});

console.log(Object.isFrozen(record));
console.log(Object.isFrozen(record.source));
console.log(Object.isFrozen(record.tags));

try {
  record.source.line = 99;
} catch (error) {
  console.log(`inner object write: ${error.constructor.name}`);
}
try {
  record.tags.push("new");
} catch (error) {
  console.log(`array add: ${error.constructor.name}`);
}

console.log(JSON.stringify(record));
```

```
true
true
true
inner object write: TypeError
array add: TypeError
{"sensor":"S-01","value":21.4,"source":{"file":"measurement-2.csv","line":17},"tags":["raw","temperature"]}
```

Arrays are objects too, so they go through the same operation; adding to a frozen array
throws an error. Using `getOwnPropertyNames` ensures non-enumerable fields are covered
too; because accessor properties have no `value` field, the getter function is not run.
The choice of `WeakSet` is deliberate: the marking is temporary and should not extend the
lifetime of the objects being walked. This collection's details belong to the Advanced
Collections topic.

Deep freezing's cost is directly proportional to object count; applying it on every
creation is expensive for large trees. The common practice is to use freezing only on
shared, long-lived data.

## Producing Instead of Changing

Once an object is frozen, an "update this field" request turns into a question: how is an
updated new object produced? Object spread looks sufficient for this, but it carries a
cost related to the chain.

```js
class MeasurementRecord {
  constructor(sensor, value, time) {
    this.sensor = sensor;
    this.value = value;
    this.time = time;
    Object.freeze(this);
  }
  format() {
    return `${this.sensor}@${this.time}: ${this.value}`;
  }
  withValue(newValue) {
    const copy = Object.create(Object.getPrototypeOf(this));
    Object.assign(copy, this, { value: newValue });
    return Object.freeze(copy);
  }
}

const original = new MeasurementRecord("S-01", 21.4, 1000);
const spread = { ...original, value: 30 };
const viaMethod = original.withValue(30);

console.log(original.format());
console.log(JSON.stringify(spread));
console.log(typeof spread.format);
console.log(Object.getPrototypeOf(spread) === MeasurementRecord.prototype);
console.log(Object.isFrozen(spread));

console.log(viaMethod.format());
console.log(Object.getPrototypeOf(viaMethod) === MeasurementRecord.prototype);
console.log(viaMethod instanceof MeasurementRecord);
console.log(Object.isFrozen(viaMethod));
console.log(original.value);
```

```
S-01@1000: 21.4
{"sensor":"S-01","value":30,"time":1000}
undefined
false
false
S-01@1000: 30
true
true
true
21.4
```

The object produced by spread carries the right data but **has lost its chain**: its
prototype becomes `Object.prototype`, `format` is not found, the `instanceof` result is
negative, and the object is not frozen. The sentence from the course's first lesson finds
its counterpart here — copying carries own properties, not the chain.

Because the `withValue` method sets up the chain with
`Object.create(Object.getPrototypeOf(this))`, the result is a genuine measurement record.
Using the current prototype instead of the constructor brings an extra gain too: when
called from a subclass, the subclass's prototype is preserved.

The last line confirms the pattern's purpose: the original record has not changed. When a
change is wanted, the old object stays in place and a new one is produced. This is the
object model's counterpart of the immutability approach introduced in the Introduction to
Functional Programming lesson of the Programming Fundamentals course.

## Summary

- `Object.freeze` makes own properties non-writable and non-configurable, forbids adding
  new properties and changing the prototype; it cannot be undone.
- Freezing is shallow: inner objects and the prototype object are unaffected, so behavior
  can still be changed.
- The scopes are nested — preventing extensions, sealing, freezing; the middle option
  fixes the field set while leaving values free.
- Deep freezing is a recursive walk; it needs visit-marking against cycles and its cost
  grows with object count.
- An update on a frozen object turns into producing a new object; because object spread
  loses the chain, the prototype must be set up explicitly.

## Next Step

Throughout this topic, methods always worked through `this`: `format` read the sensor
from the record, the accessor read the raw value from the object itself. But what `this`
turns out to be depends not on where the function is defined, but on **how it is
called** — a function coming from the chain can also run on an object it has never seen.
The next topic will separate these binding rules under four headings and examine the
cases where a method becomes detached from its object.
