Skip to content
academia.sh

Lesson 05 / 17

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.

Contents

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.

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.

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.

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.

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.

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close