058: Nested Data, Immutability, and Functional Updates
Learning outcomes
By the end of this lesson, you can:
- sketch and traverse arrays of objects containing nested objects and arrays;
- use optional chaining and
??when absence is expected; - update one nested record in an immutable style;
- copy every container on an updated path while retaining safe references elsewhere;
- separate lookup, transformation, and summary operations.
Retrieval warm-up
Before working with nested data, retrieve three ideas that will matter throughout the lesson:
- What kind of copy does
{ ...product }make? - Which value wins when duplicate keys are spread into an object?
- Which array method is suited to updating one matching item while preserving array length?
Answers: shallow, the last written value, and map(). Those answers are the
foundation for the update patterns later: spreading copies only one container,
later properties overwrite earlier ones, and map() gives us a same-length array
with a deliberate choice about which item gets a new object.
Vocabulary
- Data shape: the arrangement and expected types of fields in data (course term).
- Traverse: follow properties and array elements to reach a value (course term).
- Path: the sequence of containers from a root to a nested value (course term).
- Immutable-style update: return new changed containers rather than modify input containers (course term).
- Structural sharing: reuse unchanged objects while replacing changed paths (course term).
- Optional chaining: ?. short-circuits to undefined when the left side is nullish. — Source: MDN: Optional chaining
- Nullish coalescing: ?? yields right operand only when left is null/undefined. — Source: MDN: Nullish coalescing
- Normalization: organizing repeated entities by ID; an advanced design option, not today's requirement (course term).
- Optional chaining (official): "Optional chaining (?.) accesses a property without throwing if the reference is nullish." — Source: MDN: Optional chaining
- Immutable update (official): "An immutable update creates a new object/array with changes, leaving the original unchanged." — Source: MDN: Immutability and copying
Beginner mental model
Nested data is easiest to reason about when you treat it as a set of labeled
boxes. To read store.orders[0].customer.address.city, open one box at a time:
start at store, select orders, select the first order, and then follow
customer, address, and city. At each step, the shape tells you what kind of
value you should expect. Sketching that shape before coding is often faster than
debugging a mistaken property path later:
store (object)
products (array)
product (object)
supplier (object)
tags (array)
orders (array)
order (object)
customer (object)
address (object, optional)
items (array of objects)
That box model is useful for reading data, but it also explains updates. For an immutable-style update, do not clone the entire world and do not modify the innermost box in place. Create a new box at every level on the path from the root to the changed value. Branches outside that path can safely keep their old references. That intentional reuse is structural sharing.
Optional chaining is designed for legitimate absence:
const city = order.customer.address?.city ?? "Collection point";
If address is null or undefined, ?. stops the property access and produces
undefined; ?? then supplies the fallback. This is not a general-purpose way
to hide bugs. If every order must have a customer, writing
order.customer?.name can turn malformed data into a quiet undefined. Validate
required fields, and reserve ?. for branches that are genuinely optional.
Worked beginner example: inventory and orders
Here is a small store with two related collections. Products contain nested supplier and tag data. Orders refer to products by stable ID and contain their own customer and line-item data:
const store = {
products: [
{
id: "p1",
name: "Notebook",
price: 4,
stock: 12,
tags: ["study", "paper"],
supplier: { id: "s1", name: "Paper Co" },
},
{
id: "p3",
name: "Water Bottle",
price: 16,
stock: 7,
tags: ["travel", "reusable"],
supplier: { id: "s2", name: "Hydrate Ltd" },
},
],
orders: [
{
id: "o1",
customer: {
name: "Maya",
address: { city: "Chennai" },
},
items: [
{ productId: "p1", quantity: 2 },
{ productId: "p3", quantity: 1 },
],
},
{
id: "o2",
customer: { name: "Ravi" },
items: [{ productId: "p3", quantity: 2 }],
},
],
};
const travelProducts = store.products
.filter((product) => product.tags.includes("travel"))
.map((product) => product.name);
const orderCities = store.orders.map(
(order) => order.customer.address?.city ?? "Collection point",
);
function getOrderLines(store, orderId) {
const order = store.orders.find((item) => item.id === orderId);
if (!order) {
return [];
}
return order.items.map((item) => {
const product = store.products.find(
(item) => item.id === item.productId,
);
return {
name: product?.name ?? "Unknown product",
quantity: item.quantity,
lineTotal: product ? product.price * item.quantity : 0,
};
});
}
console.log(travelProducts);
console.log(orderCities);
console.log(getOrderLines(store, "o1"));
Output:
["Water Bottle"]
["Chennai", "Collection point"]
[
{ name: "Notebook", quantity: 2, lineTotal: 8 },
{ name: "Water Bottle", quantity: 1, lineTotal: 16 }
]
There are three separate operations here. filter() narrows the products,
map() transforms the remaining products into names, and getOrderLines() first
looks up an order and then transforms each line item. The order stores a stable
product ID instead of duplicating the complete product record. The function joins
the two collections when it needs display data, and it handles both a missing
order and a deleted or otherwise missing product explicitly.
Worked immutable-style update
Suppose the stock for one product changes. The update should return a new store without changing the original one:
function setProductStock(store, productId, nextStock) {
return {
...store,
products: store.products.map((product) =>
product.id === productId
? { ...product, stock: nextStock }
: product,
),
};
}
const updatedStore = setProductStock(store, "p3", 5);
console.log(updatedStore.products[1].stock); // 5
console.log(store.products[1].stock); // 7
console.log(updatedStore === store); // false
console.log(updatedStore.products === store.products); // false
console.log(updatedStore.products[0] === store.products[0]); // true
console.log(updatedStore.orders === store.orders); // true
The root object, the products array, and the changed product are new. The
unchanged Notebook object and the orders array retain their references. That is
correct structural sharing: only the path that changed was replaced. The phrase
"immutable style" describes what this function does with its input; JavaScript
does not automatically freeze the objects it returns.
The same rule applies deeper in the structure. To update
customer.address.city for one order, copy the five relevant containers: the
root, the orders array, the matching order, the customer, and the address.
function setOrderCity(store, orderId, city) {
return {
...store,
orders: store.orders.map((order) =>
order.id === orderId
? {
...order,
customer: {
...order.customer,
address: {
...order.customer.address,
city,
},
},
}
: order,
),
};
}
Spreading undefined in an object literal contributes no properties in modern
JavaScript, so this code also creates an address when one was absent. In business
code, making that intent explicit can be clearer:
...(order.customer.address ?? {}).
Intermediate example: remove an order line
Removing a line combines two decisions. Use map() to find the order while
keeping the orders array the same length, and use filter() inside that order to
remove the matching line. Again, copy only the containers on the changed path:
function removeOrderItem(store, orderId, productId) {
return {
...store,
orders: store.orders.map((order) => {
if (order.id !== orderId) {
return order;
}
return {
...order,
items: order.items.filter(
(item) => item.productId !== productId,
),
};
}),
};
}
const withoutNotebook = removeOrderItem(store, "o1", "p1");
console.log(withoutNotebook.orders[0].items);
// [{ productId: "p3", quantity: 1 }]
console.log(store.orders[0].items.length); // 2
The contract remains straightforward: a store goes in and a new store comes out.
The original line array is untouched. A production system still needs a policy
for a missing order or item. Returning an equivalent new root is one valid policy;
throwing an error is another. The choice belongs to the surrounding business
contract, not to map() or filter() themselves.
Interview focus: pure functions and reference-safe updates
A pure function is deterministic for the same relevant inputs and has no observable side effects. It does not log, mutate an input object, write outside state, or depend on an uncontrolled clock or random source. This makes pure functions easy to test: a test can compare input and output without resetting hidden state.
function addTax(price, rate) {
return price * (1 + rate);
}
function unsafeAddTag(product, tag) {
product.tags.push(tag); // mutates a caller-owned nested array
return product;
}
function addTag(product, tag) {
return { ...product, tags: [...product.tags, tag] };
}
The third function is pure for its inputs. An application can still call an impure operation at its boundary, such as logging or writing state, while keeping the calculation itself pure. Purity concerns observable behavior and uncontrolled dependencies; it does not require avoiding every local variable.
Follow-ups to practice: Which references must change in addTag? The product and
the tags array. Which can remain shared? Unchanged nested branches. Is a function
that returns a new array but mutates each object pure? No. A new outer container
does not undo mutation of the objects inside it.
Optional advanced extension
Repeated .find() inside .map() is easy to read for a small learning dataset,
but it repeats the search for every line. For larger collections, build an ID
lookup once and then retrieve products directly:
const productById = new Map(
store.products.map((product) => [product.id, product]),
);
const lines = store.orders[0].items.map((item) => {
const product = productById.get(item.productId);
return {
name: product?.name ?? "Unknown product",
quantity: item.quantity,
};
});
This is an optional performance extension, not a requirement to redesign every small object graph. Do not normalize small data prematurely when direct traversal is easier to understand. Choose the lookup structure when the repeated search is actually a meaningful cost or when the data model benefits from it.
Common mistakes and debugging
- Losing the shape: sketch objects/arrays and log one level at a time.
- Wrong method at a level:
find()for one record,filter()for many/removal,map()for same-length updates. - Mutating before copying:
product.stock = 5already changes shared data. Build the copy directly. - Copying only the root:
{ ...store }still sharesproductsand each product. - Copying everything: deep cloning is wasteful and obscures which branch changed.
- Using
||for quantities:0 || fallbackreplaces valid zero. Use??when only absence deserves fallback. - Optional chaining required fields: it can convert malformed data into quiet
undefined. - Comparing names instead of IDs: names may change or duplicate; use stable IDs.
When a result is wrong, inspect the boundary where the expectation first diverges.
Check the data shape, then the method selected at each level, then the identities
of the old and new containers with ===. A displayed value may look correct even
though an earlier mutation changed the original state. Conversely, an unexpected
fallback may indicate missing optional data or malformed required data; those are
different problems and should not be debugged the same way.
Best practices
- Sketch unfamiliar API-shaped data before traversing it.
- Validate required boundaries; use
?.for genuinely optional branches. - Copy every container on the changed path and only that path.
- Match array methods to intent and avoid giant nested reducers.
- Use stable IDs to relate products, orders, and cart lines.
- Write update functions as input-to-output transformations and verify originals remain unchanged.
Checkpoint
Before examining an implementation, name a target such as "quantity of product p3
in order o1." List the complete path and mark every container that must become
new. Then mark unrelated branches that may remain shared. After coding, verify
those predictions with === comparisons rather than checking only displayed
values. A correct result with accidental mutation is still an incorrect state
update; identity checks expose that class of bug.
For traversal practice, deliberately remove one optional address and one required customer. Explain why a fallback is appropriate for the address, while validation or an error is appropriate for the missing customer. This distinction is the reason optional chaining should be applied selectively rather than added to every property access.
Exercises
Core
Return all supplier names from store.products, then return the city for order
o2 with fallback "Collection point".
const supplierNames = store.products.map(
(product) => product.supplier.name,
);
const order = store.orders.find((order) => order.id === "o2");
const city = order?.customer.address?.city ?? "Collection point";
console.log(supplierNames);
console.log(city);
Output:
["Paper Co", "Hydrate Ltd"]
Collection point
Practice
Write addProductTag(store, productId, tag) so that it returns a new store and
appends the tag only to the matching product.
function addProductTag(store, productId, tag) {
return {
...store,
products: store.products.map((product) =>
product.id === productId
? { ...product, tags: [...product.tags, tag] }
: product,
),
};
}
const nextStore = addProductTag(store, "p1", "bestseller");
console.log(nextStore.products[0].tags);
console.log(store.products[0].tags);
Output:
["study", "paper", "bestseller"]
["study", "paper"]
Professional Extension
Write setOrderItemQuantity(store, orderId, productId, quantity) with
immutable-style updates. Preserve references for every unrelated order and item.
function setOrderItemQuantity(store, orderId, productId, quantity) {
return {
...store,
orders: store.orders.map((order) =>
order.id === orderId
? {
...order,
items: order.items.map((item) =>
item.productId === productId
? { ...item, quantity }
: item,
),
}
: order,
),
};
}
const next = setOrderItemQuantity(store, "o1", "p3", 4);
console.log(next.orders[0].items[1].quantity); // 4
console.log(store.orders[0].items[1].quantity); // 1
console.log(next.orders[1] === store.orders[1]); // true
console.log(next.orders[0].items[0] === store.orders[0].items[0]); // true
Recap
Describe the data path to an order city. What does optional chaining protect against? Which containers change when product stock changes? Why is sharing the unchanged orders array safe? Explain why immutable-style updating is more precise than blindly deep-cloning everything.
Official references
- MDN: Working with objects
- MDN: Optional chaining
- MDN: Nullish coalescing
- MDN: Spread syntax
- ECMA-262: Property Accessors
Reusable function utilities
These small utilities are useful only when their contracts are explicit. The memoized function below caches by argument identity in this example; it is not a general serialization of arguments. In particular, two separately created but structurally identical objects are different identity keys.
function memoize(fn) {
const root = { children: new Map(), hasValue: false, value: undefined };
return (...args) => {
let node = root;
for (const arg of args) {
if (!node.children.has(arg)) {
node.children.set(arg, {
children: new Map(), hasValue: false, value: undefined,
});
}
node = node.children.get(arg);
}
if (!node.hasValue) { node.hasValue = true; node.value = fn(...args); }
return node.value;
};
}
let calls = 0;
const square = memoize((number) => { calls += 1; return number * number; });
console.assert(square(3) === 9 && square(3) === 9 && calls === 1);
once has a different contract: it runs the wrapped function at most once and
returns the first result, including undefined:
function once(fn) {
let called = false;
let result;
return (...args) => {
if (!called) { called = true; result = fn(...args); }
return result;
};
}
let starts = 0;
const start = once(() => ++starts);
console.assert(start() === 1 && start() === 1 && starts === 1);
Currying collects arguments across calls. compose runs functions from right to
left, while pipe, often called left-to-right compose, runs them from left to
right:
const curry = (fn, collected = []) => (...args) => {
const all = [...collected, ...args];
return all.length >= fn.length ? fn(...all) : curry(fn, all);
};
const add = curry((a, b, c) => a + b + c);
console.assert(add(1)(2, 3) === 6);
const compose = (...fns) => (value) => fns.reduceRight((v, fn) => fn(v), value);
const pipe = (...fns) => (value) => fns.reduce((v, fn) => fn(v), value);
console.assert(compose((x) => x * 2, (x) => x + 1)(3) === 8);
console.assert(pipe((x) => x + 1, (x) => x * 2)(3) === 8);
Finally, flatten nested arrays recursively. The implementation retains falsy
values such as 0 and false, and it also handles empty arrays:
function flatten(values) {
return values.reduce(
(result, value) => result.concat(Array.isArray(value) ? flatten(value) : value),
[],
);
}
console.assert(JSON.stringify(flatten([1, [2, [0, false]], [], null])) ===
JSON.stringify([1, 2, 0, false, null]));
Interview questions: What does the memoization key mean for objects? How would
you bound or invalidate a cache? What happens if the wrapped once function
throws? Why does compose(f, g)(x) call g first? What is the time and space
cost of recursive flatten?
