Before you begin
Why this matters
Code that changes an object in place changes it for every name that leads to it, including code that wanted the old value. An update that returns a new object avoids that, and lets you keep each old version, as long as you know which parts the new object still shares.
What this helps you reason about
- Write an update with a spread that leaves the old object unchanged.
- Predict which parts the new object shares with the old one, and check it with ===.
- Change a part deep inside without changing the old object, and keep old versions for undo.
A trip plan goes to Oslo for 2 days and stays at the Fjord hotel. The traveller adds a day. This lesson builds the 3-day plan as a new object, keeps the 2-day plan as it was, and follows which parts the two plans share.
Already know this? The short version
- { ...trip, days: 3 } builds a new object with one property replaced; trip is unchanged, and trip === result is false.
- The spread copies one level: object and array properties keep their references, so the new object shares them, which === shows.
- To change a nested property, spread every level on the path: { ...trip, hotel: { ...trip.hotel, nights: 3 } }. Writing through a spread copy changes the shared part.
- Keeping old versions for undo is cheap because unchanged parts are shared; but a spread of an array copies every element reference, so each update costs time in proportion to the level it copies.
Words in this lesson (8)
- immutable update
- A change made by building a new value from the old one, with the change in it. The old value stays as it was.
- object spread
- Three dots before an object inside braces, as in { ...trip, days: 3 }. It copies each property of trip into the new object; a property written after it replaces the copied one.
- reference
- A value that leads to an object. A variable whose value is an object holds a reference, not a copy of the object, so copying the variable gives a second way to reach the same object.
- identity
- Whether two references lead to the same object. Two objects can hold equal contents and still be two objects.
- structural sharing
- Letting the new value of an update reuse the parts of the old value that did not change, instead of copying them. Both values then lead to those parts.
- shallow copy
- A copy of the outer value only. Objects, lists, or pointers stored inside it are copied as references, so the copy shares them with the original.
- mutation
- Changing the inside of an object, as in counter.value = 5. Every name that refers to that object sees the change. Reassignment (count = 5) is different: it changes only which value one name holds.
- persistent data structure
- A data structure that is never changed in place: each update gives a new version, and every older version stays usable.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Update
How do you “change” a value without mutating it?
To mutate an object is to change it in place: trip.days = 3. Every name that leads to that object sees the change. An immutable update does something else: it builds a new object from the old one, with the change in it, and leaves the old object as it was.
The object spread does most of the work. { ...trip, days: days } makes a new object, copies each property of trip into it, and then sets days, which replaces the copied value. withDays below returns that new object. trip still says 2 days, and longer says 3.
Shallow and deep copying used the spread to copy. Here the spread is part of an update: the copy and the change are one expression, and nothing is changed afterwards.
type Trip = { city: string; days: number };
function withDays(trip: Trip, days: number): Trip {
return { ...trip, days: days };
}
const trip: Trip = { city: "Oslo", days: 2 };
const longer = withDays(trip, 3);
// trip.days is 2, longer.days is 3 An immutable update returns a new object with the change in it; the old object is never written.
Coming from React: A state setter needs a new object, as in setTrip({ ...trip, days: 3 }). React compares the old and new state with Object.is, so a mutated object looks unchanged.
Precisely: readonly and Object.freeze
type Trip = Readonly<{ city: string; days: number }> makes the compiler refuse trip.days = 3, but only at compile time. Object.freeze(trip) makes the write fail while the program runs (it throws in a module, which runs in strict mode). Both reach one level, like the spread.
Experiment
Return a new trip with one property changed
The spread builds a new object and copies each property of trip into it; days: days after the spread replaces one of them. trip itself is never written. After the edit, withDays changes the object that trip leads to and returns that same object.
Why it matters An update function that returns a new object leaves every older reference to the old object unchanged. === tells you which kind of function you called.
Predict both printed lines. Then change return { ...trip, days: days }; to trip.days = days; return trip; and predict again.
type Trip = { city: string; days: number };
function withDays(trip: Trip, days: number): Trip {
return { ...trip, days: days };
}
const trip: Trip = { city: "Oslo", days: 2 };
const longer = withDays(trip, 3);
console.log(trip.days, longer.days);
console.log(trip === longer);
What to look for
Step into withDays: a frame named withDays appears above <top level>, with trip {city: "Oslo", days: 2} and days 3. Back at the top level, trip still shows days: 2 and longer shows days: 3. The run prints 2 3, then false. After the edit, trip shows days: 3 as soon as withDays returns, and the run prints 3 3, then true: longer is trip itself. A const that has not run yet shows as not initialized yet, and Step can stop on one line twice; both are normal for this debugger.
Expected output
2 3
false03 / Deep inside
How do you “change” a part deep inside?
Sharing is safe only while nobody writes into a shared part. Below, the spread makes a new trip, but longer.hotel is the hotel that trip leads to. Writing longer.hotel.nights = 3 changes that one hotel, so trip now says 3 nights too.
To change a part deep inside, build a new object on each level of the path to it, from the top down. The new trip gets a new hotel; the new hotel gets the new nights. Everything off the path, such as city and the hotel’s name, is still shared.
// Wrong: the hotel is shared, so trip changes too.
const copy = { ...trip };
copy.hotel.nights = 3;
// Right: a new object on each level of the path.
const longer = {
...trip,
hotel: { ...trip.hotel, nights: 3 },
}; To change a nested part, build a new object on every level of the path to it; never write through a spread copy.
Coming from Python: A dict copy with {**trip, "days": 3} is shallow in the same way: a nested dict is shared.
Precisely: arrays
Arrays work the same way. [...stops, "Voss"] is a new array with one more element, and stops.map((s, i) => (i === 1 ? "Flam" : s)) is a new array with one element replaced. Node.js 22 also has toSorted, toReversed, and with(index, value), which return a changed copy instead of changing the array.
Experiment
Change the hotel through a spread copy
The spread made a new trip, but its hotel property holds the same reference, so the write goes into the one hotel both trips share. The nested spread builds a new hotel and a new trip that leads to it: one new object on each level of the path to the change.
Why it matters To change a part deep inside, build a new object on every level from the top down to the change. Writing through a copy changes the part it shares.
Predict both printed lines. Then replace the two lines const longer = { ...trip }; and longer.hotel.nights = 3; with const longer = { ...trip, hotel: { ...trip.hotel, nights: 3 } }; and predict again.
const trip = {
city: "Oslo",
hotel: { name: "Fjord", nights: 2 },
};
const longer = { ...trip };
longer.hotel.nights = 3;
console.log(trip.hotel.nights, longer.hotel.nights);
console.log(longer.hotel === trip.hotel);
What to look for
Step past longer.hotel.nights = 3. Both trip and longer now show hotel {name: "Fjord", nights: 3}. The run prints 3 3, then true. After the edit, trip keeps nights: 2, longer shows nights: 3, and the run prints 2 3, then false.
Expected output
3 3
true04 / History
Why are old versions cheap, and what does an update cost?
Because no update changes an old version, you can keep every version. An undo list is an array of versions: each change pushes a new trip, and undo goes back to the one before. Data kept this way is called a persistent data structure: each update gives a new version, and every older version stays usable.
Keeping versions is cheap because the versions share their unchanged parts. Each update still costs something. A spread copies every property of the level it rebuilds, and a spread of an array copies every element reference, so adding one stop to a plan with 1,000 stops copies 1,000 references. A deep copy would cost more: a new object on every level. Mutation costs nothing extra, but it leaves no old version to go back to.
const versions: Trip[] = [trip];
versions.push(withDays(versions[versions.length - 1], 3));
versions.push(withDays(versions[versions.length - 1], 4));
// undo: drop the last version
versions.pop();
console.log(versions[versions.length - 1].days); // 3 Old versions stay usable and share their unchanged parts; each update costs one new object per level it rebuilds.
Precisely: libraries for large collections
A spread of a long array copies the whole level, so updates get slower as arrays grow. Libraries such as Immutable.js store a list as a tree and rebuild only the nodes on the path to a change, sharing the rest. Immer lets code that looks like mutation produce a new object with structural sharing.
05 / Try it
Can you explain a fresh example?
Decide for each object whether it is new or shared. The next lesson, Stack and heap, shows where values are stored while a program runs. Its link is at the end of the page.
const plan = {
city: "Bergen",
hotel: { name: "Kai", nights: 1 },
stops: ["Voss"],
};
const next = { ...plan, hotel: { ...plan.hotel, nights: 2 } };
console.log(
plan.hotel.nights,
next.hotel === plan.hotel,
next.stops === plan.stops,
);
What is printed?
Reveal the reasoning
1 false true. next is a new object with a new hotel, built with nights: 2, so plan.hotel keeps 1 night and the two hotels are different objects. The update did not rebuild stops, so next.stops leads to the same array as plan.stops.
Practice tickets
- IMMUT-101 · The seat change summary shows the same seat twice → Free
- IMMUT-102 · The booking screen shows the wrong luggage after a weight request → Sign in · Pro
- IMMUT-103 · Editing one stage redraws stages that did not change → Sign in · Pro