Skip to content
academia.sh

Lesson 10 / 14

Immutability

Eliminating shared state: counting the silent changes a shallow copy leaves behind, comparing the object counts that deep copying and structural sharing produce, and enforcing the rule with freezing.

Contents

The previous lesson established the two conditions of a pure function, and calculateFee satisfied them: it took the tariff as a parameter and wrote nowhere. But nothing guaranteed that the function would not change its own parameter. The tariff object is a single object, and every place that uses it sees the same object; the moment one line changes it, the results of two calls that know nothing of each other become tied together.

This lesson takes on that tie. Three things are measured: how many fees silently shift when the shared data is changed, how many objects copying costs, and how much of that cost may not need to be paid.

Two Separate Terms

Two terms are commonly confused, and this platform keeps them separate. Immutable data is a value whose content cannot be changed once it is produced; a tier fee in the tariff is an example. An invariant is a condition that must always stay true: “the calculated fee cannot fall below the minimum fee” is an invariant.

The relationship runs one way: immutable data makes protecting invariants cheaper. If a value is validated the moment it is built and can never be changed afterward, the question of whether the condition later broke disappears. Encapsulation produces the same result by a different route: it hides state and exposes only the behavior that protects the invariant. Both work against the same problem — a condition that breaks later.

The Shared Tariff

The fee library loads the tariff from configuration. The structure is nested: tiers, zone factors, carrier options, and discount definitions.

// tariff-data.mjs — the tariff is loaded from configuration; every load returns a fresh structure
export const loadTariff = () => ({
  name: "domestic-standard",
  minFee: 4990,
  tiers: [
    { maxWeightGrams: 1000, fee: 4990 },
    { maxWeightGrams: 5000, fee: 6490 },
    { maxWeightGrams: 15000, fee: 10900 },
  ],
  zoneFactor: { 1: 1.0, 2: 1.15, 3: 1.35 },
  carriers: [
    { name: "fast", extraFee: 2500, route: ["IST", "ANK"] },
    { name: "economy", extraFee: 0, route: ["IST", "ESK", "ANK"] },
    { name: "delivery-point", extraFee: -750, route: ["IST", "ANK", "point"] },
  ],
  discounts: [{ name: "contracted", rate: 0.88 }, { name: "volume", rate: 0.95 }],
});

export const SHIPMENTS = [
  { code: "TR-4471", weightGrams: 800, zone: 1 },
  { code: "TR-4472", weightGrams: 3200, zone: 2 },
  { code: "TR-4473", weightGrams: 12400, zone: 3 },
  { code: "TR-4474", weightGrams: 950, zone: 2 },
  { code: "TR-4475", weightGrams: 4800, zone: 1 },
  { code: "TR-4476", weightGrams: 14900, zone: 2 },
];

export function calculateFee(shipment, tariff) {
  const tier = tariff.tiers.find((t) => shipment.weightGrams <= t.maxWeightGrams)
    ?? tariff.tiers.at(-1);
  return Math.max(tariff.minFee,
    Math.round(tier.fee * tariff.zoneFactor[shipment.zone]));
}

A contracted customer needs the tier fees reduced by twelve percent. This is deriving a new tariff from the standard one, and it can be written three separate ways.

// derive.mjs — three ways to derive the contracted tariff from the same source
let created = 0;
const create = (obj) => { created++; return obj; };
export const resetCounter = () => { created = 0; };
export const readCounter = () => created;

// 1. In-place mutation: only the top level is copied, the nested structure keeps being shared.
export function deriveWithShallowCopy(tariff, rate) {
  const copy = create({ ...tariff });
  for (const t of copy.tiers) t.fee = Math.round(t.fee * rate);
  return copy;
}

// 2. Deep copy: every node is rebuilt, nothing is shared.
export function deriveWithDeepCopy(tariff, rate) {
  return create({
    ...tariff,
    tiers: create(tariff.tiers.map((t) =>
      create({ ...t, fee: Math.round(t.fee * rate) }))),
    zoneFactor: create({ ...tariff.zoneFactor }),
    carriers: create(tariff.carriers.map((c) =>
      create({ ...c, route: create([...c.route]) }))),
    discounts: create(tariff.discounts.map((d) => create({ ...d }))),
  });
}

// 3. Structural sharing: only the path of the change is rebuilt, the rest is shared.
export function deriveBySharing(tariff, rate) {
  return create({
    ...tariff,
    tiers: create(tariff.tiers.map((t) =>
      create({ ...t, fee: Math.round(t.fee * rate) }))),
  });
}

The first path is the product of a common misconception: because a copy is taken with the spread syntax, the source is assumed to be preserved. The copy obtained is a shallow copy; the root object is new, but the tiers array and the objects inside it are the same objects. Two names reaching the same object is called aliasing, and it is the source of the problem.

Counting the Silent Change

The measurement places two run paths side by side: six shipments are charged with the same tariff, then the contracted tariff is derived, then the same six shipments are charged again. How many of the standard tariff’s fees changed is counted.

// measure.mjs — the silent-change and created-object count for the three derivation paths
import { loadTariff, SHIPMENTS, calculateFee } from "./tariff-data.mjs";
import { deriveWithShallowCopy, deriveWithDeepCopy, deriveBySharing,
  resetCounter, readCounter } from "./derive.mjs";

const CONTRACT_RATE = 0.88;
const prices = (t) => SHIPMENTS.map((g) => calculateFee(g, t));

function measure(name, derive) {
  const tariff = loadTariff();
  const before = prices(tariff);
  resetCounter();
  const contracted = derive(tariff, CONTRACT_RATE);
  const createdObjects = readCounter();
  const after = prices(tariff);
  const silent = before.filter((u, i) => u !== after[i]).length;
  const shared = ["zoneFactor", "carriers", "discounts"]
    .filter((field) => contracted[field] === tariff[field]).length;
  console.log(`${name.padEnd(20)} silent changes: ${silent}/6  ` +
    `created objects: ${String(createdObjects).padStart(2)}  shared subtrees: ${shared}/3`);
  return after;
}

const s = measure("shallow copy", deriveWithShallowCopy);
measure("deep copy", deriveWithDeepCopy);
measure("structural sharing", deriveBySharing);

console.log(`\nstandard tariff before : ${prices(loadTariff()).join(", ")}`);
console.log(`after shallow copy     : ${s.join(", ")}`);
shallow copy         silent changes: 5/6  created objects:  1  shared subtrees: 3/3
deep copy            silent changes: 0/6  created objects: 16  shared subtrees: 0/3
structural sharing   silent changes: 0/6  created objects:  5  shared subtrees: 3/3

standard tariff before : 4990, 7463, 14715, 5739, 6490, 12535
after shallow copy     : 4990, 6568, 12949, 5050, 5711, 11031

Five of the six standard fees dropped through the shallow-copy path. No one intended to change these fees; the only intent was to derive a separate contracted tariff. The one fee that did not change is the first shipment’s, and what protected it was not design — it is that the minimum-fee floor keeps the calculation at 4990.

This kind of defect has two traits. It is silent: no error is thrown, no line is logged. And it is distant: the defect’s symptom is where the standard tariff charges a fee, its cause is where the contracted tariff is derived. The distance between the two is nothing more than two places aliasing the same object.

The Cost of Copying

Deep copying reduces the silent change to zero, but it creates sixteen objects. The change to the tariff is only in the tiers; the zone factors, the three carriers, the three route arrays, and the two discount definitions have all been copied as they were. These copied objects will never be changed.

The third path uses this observation. Structural sharing rebuilds only the nodes along the path of the change; unchanged subtrees are shared by reference. The result provides the same protection with five objects: the root, the tier array, and the three tiers. The number of shared subtrees is three — the derived tariff’s carriers array and the standard tariff’s carriers array are the same object.

The condition for sharing to be safe is that the shared parts are also not changed. When the rule is applied consistently, sharing is protection for free; the moment it breaks in a single place, the shared subtree produces the same silent defect as the shallow copy did. The trade-off is clear here: deep copy is resistant to the rule being violated and pays with sixteen objects, structural sharing pays with five objects and relies on the rule being applied everywhere.

Enforcing the Rule with the Language

Enforcing is also an option, instead of relying on trust. Freezing was introduced in the JavaScript Objects and Functions course: an object’s properties become non-writable, and an attempted write in strict mode throws an error. Modules run in strict mode, so no additional setting is needed.

// freeze.mjs — immutability enforced by the language: a silent change becomes an error
import { loadTariff, SHIPMENTS, calculateFee } from "./tariff-data.mjs";
import { deriveWithShallowCopy } from "./derive.mjs";

function deepFreeze(obj) {
  for (const value of Object.values(obj)) {
    if (value !== null && typeof value === "object") deepFreeze(value);
  }
  return Object.freeze(obj);
}

const tariff = deepFreeze(loadTariff());
const before = SHIPMENTS.map((g) => calculateFee(g, tariff));

let errors = 0;
let errorType = "-";
try {
  deriveWithShallowCopy(tariff, 0.88);
} catch (e) {
  errors = 1;
  errorType = e.constructor.name;
}

const after = SHIPMENTS.map((g) => calculateFee(g, tariff));
console.log(`errors thrown  : ${errors} (${errorType})`);
console.log(`silent changes : ${before.filter((u, i) => u !== after[i]).length}/6`);
errors thrown  : 1 (TypeError)
silent changes : 0/6

The code has not changed; the same deriveWithShallowCopy is called. What changed is where the defect surfaces: instead of five fees silently shifting, an error on the line that produces the defect. The error type differs across languages and runtimes, but the nature of the behavior is the same — what was silent has become visible.

Freezing has a cost of its own. Deep freezing walks every node and runs on every load; in large configurations this cost should not be adopted without measuring it. A common compromise is to keep freezing on only in development runs and sustain the rule in production through code review.

Where It Is Not Paid

The immutability rule does not have to apply to every line. The criterion is again observability: an object a function produces inside its own body and does not leak out can be freely changed. The previous lesson’s totalFee was an example of this; the accumulator changes on every step, but only the final value leaves the function.

The same reasoning applies to bulk construction. When building a list of a thousand shipments, allocating a new array on every insertion is unnecessary; the array is filled locally and frozen only once it is complete, then handed out. The rule is that data becomes immutable from the moment it is shared, not from the moment it is produced.

Summary

  • Immutable data cannot be changed after it is produced; an invariant is a condition that must always stay true. The first makes protecting the second cheaper.
  • A copy taken with the spread syntax is shallow: the nested structure keeps being shared through aliasing. Deriving the contracted tariff this way silently dropped five of six standard fees.
  • A silent defect has two traits: it throws no error, and its symptom sits far from its cause.
  • Deep copy provides the protection by creating sixteen objects; structural sharing provides the same protection with five objects and shares three subtrees by reference, but relies on the rule being applied everywhere.
  • Freezing turns trust into enforcement: the same derivation run produces one TypeError instead of five silent shifts, so the defect surfaces on its own line.
  • The rule applies to shared data; a structure a function produces and does not leak out can be freely filled.

Next Step

Both lessons have now taken on data: the fee calculation was reduced to a mapping from input to output, and the tariff was made immutable. Behavior, though, is still fixed — calculateFee still carries a single set of rules in its body. That body will grow as the contracted discount, the volume discount, the night-delivery surcharge, and corporate agreements are added. The next lesson turns behavior itself into a parameter: the same fee logic is written with a flag parameter, a lookup table, and a function parameter, and then a new discount rule is added and the number of lines touched in each of the three versions is counted.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close