JavaScript and TypeScript course Β· Module 12: Functional Programming
Immutable Data Patterns
In this lesson7
The park map and the reporting module receive the same state object. The map moves Rex to zone B by changing the field directly, and suddenly the morning shift's report shows zone B too, even though Rex was in A that morning. A shared object changed in one place changes everywhere someone holds a reference to it.
In the laboratories of Jurassic Park, a strict rule applies - you never modify the original DNA sample. Every change creates a new copy. We apply the same approach in functional programming with data - original structures remain untouched, and every modification creates a new object.
Spread Operator - Copying and Extending
The spread operator (...) is the fundamental tool for creating immutable copies of objects and arrays. Let's start by moving a dinosaur to another zone:
1// Copying an object with modification
2const dinosaur = { name: 'Rex', health: 100, zone: 'A' };
3const movedDino = { ...dinosaur, zone: 'B' };
4// dinosaur: { name: 'Rex', health: 100, zone: 'A' } - unchanged
5// movedDino: { name: 'Rex', health: 100, zone: 'B' } - new objectFields written after the spread override the copied ones, so zone: 'B' wins over zone: 'A'. movedDino and dinosaur are two different objects, and a report holding dinosaur still sees zone A.
We treat arrays the same way: we add an element with spread and remove one with filter:
1// Copying an array with added element
2const zones = ['A', 'B', 'C'];
3const extendedZones = [...zones, 'D'];
4// zones: ['A', 'B', 'C'] - unchanged
5// extendedZones: ['A', 'B', 'C', 'D']
6
7// Removing an element from an array (without mutation)
8const dinosaurs = ['Rex', 'Blue', 'Brachio', 'Stego'];
9const withoutBlue = dinosaurs.filter((d) => d !== 'Blue');
10// dinosaurs: ['Rex', 'Blue', 'Brachio', 'Stego'] - unchanged
11// withoutBlue: ['Rex', 'Brachio', 'Stego']None of these operations used push or splice, which change an array in place.
Nested Objects - Deep Immutability
The spread operator creates a shallow copy - nested objects are still shared! You must spread at every level. Here is the park state with zones inside and an attempt at a copy with a single spread:
1const park = {
2 name: 'Jurassic Park',
3 zones: {
4 A: { dinosaurs: ['Rex'], voltage: 10000 },
5 B: { dinosaurs: ['Brachio'], voltage: 5000 },
6 },
7};
8
9// WRONG - shallow copy, zones is shared
10const badCopy = { ...park };
11badCopy.zones.A.voltage = 0; // ALSO CHANGES the original!badCopy is a new object, but its zones field points to the same object as park.zones. Changing the voltage through the copy is therefore a change to the original, exactly as in the story of the map and the report.
A correct update copies every level on the path to the field being changed. We assume a fresh park object here, because the badCopy line has already zeroed the original:
1// CORRECT - deep copy at every level
2const updatedPark = {
3 ...park,
4 zones: {
5 ...park.zones,
6 A: {
7 ...park.zones.A,
8 voltage: 15000,
9 },
10 },
11};
12// park.zones.A.voltage: 10000 (unchanged)
13// updatedPark.zones.A.voltage: 15000 (new object)Only the objects on the path are new: updatedPark, its zones and zone A. Zone B was not copied, so updatedPark.zones.B === park.zones.B. That is fine, because we did not change it, and sharing the unchanged parts saves memory.
Object.freeze() - Freezing Objects
Object.freeze() prevents modification of an object at runtime. An attempt to change it is silently ignored, and in strict mode it throws a TypeError. Every ES module and every class runs in strict mode:
1const CONFIG = Object.freeze({
2 maxDinosaurs: 100,
3 maxVoltage: 50000,
4 alertThreshold: 3,
5});
6
7CONFIG.maxDinosaurs = 200; // Ignored! (or TypeError in strict mode)
8console.log(CONFIG.maxDinosaurs); // still 100Object.freeze returns the same object, just frozen: you cannot change, add or remove its fields. There is a catch, though, which shows up with an object that has a nested structure:
1// WARNING: freeze is shallow!
2const deepConfig = Object.freeze({
3 zones: { A: { limit: 10 } },
4});
5deepConfig.zones.A.limit = 999; // THIS WILL WORK - freeze is shallowOnly the first level is frozen. The zones object remained an ordinary object, so to freeze everything you have to call Object.freeze recursively on every nested object.
const Is Not freeze
A common misunderstanding: if CONFIG is declared with const, why also freeze? Because the two mechanisms protect completely different things:
1const zone = { id: 'A', voltage: 10000 };
2zone.voltage = 0; // works - const does not protect the contents
3// zone = { id: 'B' }; // TypeError - const protects the assignment
4
5const lockedZone = Object.freeze({ id: 'A', voltage: 10000 });
6lockedZone.voltage = 0; // ignored (TypeError in strict mode)const blocks reassigning the variable, that is, the reference. Object.freeze blocks changing the values inside the object. Protect a fixed configuration with both at once, as with CONFIG.
structuredClone() - Deep Copying
structuredClone() creates a deep copy of an object - all nested structures are independent. That is handy when you change many things deep inside a structure:
1const original = {
2 name: 'Jurassic Park',
3 zones: [
4 { id: 'A', dinosaurs: [{ name: 'Rex', health: 100 }] },
5 { id: 'B', dinosaurs: [{ name: 'Blue', health: 95 }] },
6 ],
7};
8
9const deepCopy = structuredClone(original);
10deepCopy.zones[0].dinosaurs[0].health = 50;
11
12console.log(original.zones[0].dinosaurs[0].health); // 100 (unchanged!)
13console.log(deepCopy.zones[0].dinosaurs[0].health); // 50 (independent copy)The change in the copy did not reach the original. structuredClone works in modern browsers and in Node.js from version 17, but it will not copy functions (it throws a DataCloneError), and it turns class instances into plain objects without methods. My advice: day to day, copy with spread only the path you are changing, and keep structuredClone for the moments when you need a fully independent copy of the data.
Immutable Array Updates
A complete set of array operations without mutation, gathered on a single list of dinosaurs:
1const dinosaurs = [
2 { id: 1, name: 'Rex', health: 100 },
3 { id: 2, name: 'Blue', health: 95 },
4 { id: 3, name: 'Brachio', health: 88 },
5];
6
7// Adding
8const added = [...dinosaurs, { id: 4, name: 'Stego', health: 100 }];
9
10// Removing
11const removed = dinosaurs.filter((d) => d.id !== 2);
12
13// Updating
14const updated = dinosaurs.map((d) =>
15 d.id === 1 ? { ...d, health: 80 } : d
16);
17
18// Sorting (without mutation - create a copy before sort)
19const sorted = [...dinosaurs].sort((a, b) => a.health - b.health);Updating with map and the conditional operator is a pattern you will see everywhere: the changed element gets a new object, the others pass through unchanged. We copy the array before sort, because sort changes it in place. Since ES2023 you also have ready-made non-mutating counterparts: toSorted, toReversed, toSpliced and with.
From Simple Techniques to Libraries
Let's order the tools from the simplest: Object.freeze guards one level, spread makes a shallow copy with a change, and structuredClone a deep copy. For large structures people reach for libraries. Immutable.js provides persistent data structures: every "change" returns a new version that shares the unchanged parts with the previous one. Immer lets you write ordinary assignments on a draft and then creates a new, immutable version by itself. The same pattern awaits you in the world of React, where state is updated with a new copy, not with mutation.
Remember: you never touch the original DNA sample - every change is a new test tube, and spread, structuredClone and Object.freeze guard that rule at different levels.
Code for this lesson: index.js
1// Immutable data patterns
2console.log("=== Jurassic Park - Protecting the Original Samples ===\n");
3
4// --- EXAMPLE 1: Spread operator ---
5console.log("--- Spread: objects ---");
6const dino = { name: "Rex", health: 100, zone: "A" };
7const moved = { ...dino, zone: "B" };
8console.log("Original:", dino);
9console.log("Moved:", moved);
10
11console.log("\n--- Spread: arrays ---");
12const zones = ["A", "B", "C"];
13const extended = [...zones, "D"];
14console.log("Original:", zones);
15console.log("Extended:", extended);
16
17// --- EXAMPLE 2: Nested objects ---
18console.log("\n--- Nested objects ---");
19const park = {
20 name: "Jurassic Park",
21 zones: {
22 A: { dinosaurs: ["Rex"], voltage: 10000 },
23 B: { dinosaurs: ["Brachio"], voltage: 5000 },
24 },
25};
26
27const updatedPark = {
28 ...park,
29 zones: {
30 ...park.zones,
31 A: { ...park.zones.A, voltage: 15000 },
32 },
33};
34
35console.log("Original A voltage:", park.zones.A.voltage);
36console.log("Updated A voltage:", updatedPark.zones.A.voltage);
37console.log("Original B voltage:", park.zones.B.voltage, "(unchanged)");
38
39// --- EXAMPLE 3: Object.freeze ---
40console.log("\n--- Object.freeze ---");
41const CONFIG = Object.freeze({
42 maxDinosaurs: 100,
43 maxVoltage: 50000,
44});
45
46CONFIG.maxDinosaurs = 200; // Ignored!
47console.log("maxDinosaurs:", CONFIG.maxDinosaurs, "(still 100)");
48
49// --- EXAMPLE 4: structuredClone ---
50console.log("\n--- structuredClone ---");
51const original = {
52 zones: [{ id: "A", dinos: [{ name: "Rex", hp: 100 }] }],
53};
54const deepCopy = structuredClone(original);
55deepCopy.zones[0].dinos[0].hp = 50;
56
57console.log("Original hp:", original.zones[0].dinos[0].hp, "(unchanged!)");
58console.log("Copy hp:", deepCopy.zones[0].dinos[0].hp);
59
60// --- EXAMPLE 5: Immutable array operations ---
61console.log("\n--- Immutable operations ---");
62const dinosaurs = [
63 { id: 1, name: "Rex", health: 100 },
64 { id: 2, name: "Blue", health: 95 },
65 { id: 3, name: "Brachio", health: 88 },
66];
67
68// Adding
69const added = [...dinosaurs, { id: 4, name: "Stego", health: 100 }];
70console.log("Added (new array):", added.length, "items");
71
72// Removing
73const removed = dinosaurs.filter((d) => d.id !== 2);
74console.log("Removed Blue:", removed.map((d) => d.name));
75
76// Updating
77const updated = dinosaurs.map((d) =>
78 d.id === 1 ? { ...d, health: 80 } : d
79);
80console.log("Updated Rex:", updated[0].health);
81console.log("Original Rex:", dinosaurs[0].health, "(unchanged!)");
82
83// Sorting (immutable)
84const sorted = [...dinosaurs].sort((a, b) => a.health - b.health);
85console.log("Sorted:", sorted.map((d) => d.name + ":" + d.health));
86console.log("Original:", dinosaurs.map((d) => d.name + ":" + d.health));Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the spread operator (...) do with nested objects?
2. Which object copying method in JavaScript creates a deep clone?
Hands-on tasks in the game
- Code editor
Add, delete, and update data in nested objects using the spread operator and filter/map.
- Vertical ordering
Arrange data immutability techniques from simplest to most advanced:
- Code editor
Build a pipeline with validation, alert classification, grouping, and statistics.
- Click in order
Arrange the elements of freezing a configuration object:
- Horizontal ordering
Arrange the elements of an immutable array element update:
- Click in order
Arrange the elements of immutably adding an element to an array: