The previous lesson ended on a clear limit: unpacking by position is fine for pairs and triples, but Nómada Tasks tasks have nine named fields and there the order means nothing. This lesson covers the other half of the syntax —the one that goes by name— and completes it with the ... operator in its most useful role: spread, which copies and combines objects and arrays without touching the originals. With those two pieces you will finally write the pattern that underpins modern web development: the immutable update, { ...task, status: 'done' }, which produces a modified task without destroying the previous one. And you will end up running head-first into its critical limit —spread copies only the first level— which is exactly the problem the module's last lesson will solve.
Contents
- Object destructuring: the name rules
- Renaming properties
- Default values (and how to combine them with renaming)
- Nested objects
- Destructuring in the parameters: the options object, now in the signature
- The warning: destructuring
undefinedand the= {}trick - Property shorthand
- Array spread
- Object spread: copying, combining, overwriting
- Order matters
- Spread for passing arguments to a function
- Rest in objects: removing fields
- Immutable updates
- Why this underpins all state work
- The critical limit: spread copies only the surface
- Common Mistakes and Tips
- Exercises
- Conclusion
- Object destructuring: the name rules
The syntax is identical to the array one, but with braces instead of brackets:
const task = {
id: 6,
title: 'Carpentry workshop quote',
assignee: 'Iván',
priority: 'high',
status: 'pending',
tags: ['carpentry', 'purchasing'],
estimatedHours: 5,
dueDate: '2026-09-05',
reviewer: 'Marta'
};
const { title, assignee, estimatedHours } = task;
console.log(title, assignee, estimatedHours);
// Carpentry workshop quote Iván 5The essential difference from arrays:
With an array you unpack by position; with an object, by property name. The order in which you write the variables is completely irrelevant.
const { estimatedHours, title } = task; // different order, same result
console.log(title); // 'Carpentry workshop quote'The trade-off is that the names must match the property names. If you get one wrong, you get undefined with no warning at all —the same silent problem as reading a non-existent property that you saw in 04-01:
And if the property does not exist, it does not fail either:
A comparison of the two kinds of destructuring:
Arrays [ ] |
Objects { } |
|
|---|---|---|
| Matches by | Position | Name |
| The names are chosen by | You, freely | They must match (unless renamed) |
| Skipping elements | With commas | Not needed: you ask only for what you want |
| Rest | ...rest → an array |
...rest → an object |
| Ideal for | Pairs, coordinates, entries() |
Entities with named fields |
- Renaming properties
If the property's name does not suit you —because it clashes with an existing variable or because you want something shorter— you can rename with a colon:
const { title: t, estimatedHours: hours, assignee: who } = task;
console.log(t); // 'Carpentry workshop quote'
console.log(hours); // 5
console.log(who); // 'Iván'
// console.log(title); ✗ ReferenceError: title does not existIt reads the opposite way from how it looks: title: t does not mean "assign t to title", but "take the title property and call it t". The mnemonic: to the left of the colon is always the property name; to the right, the variable name. It is the same order as in an object literal, but working in reverse.
Renaming is indispensable when you destructure two similar objects in the same scope:
const taskA = { title: 'Redesign the multipurpose room', estimatedHours: 12 };
const taskB = { title: 'Carpentry workshop quote', estimatedHours: 5 };
const { title: titleA, estimatedHours: hoursA } = taskA;
const { title: titleB, estimatedHours: hoursB } = taskB;
console.log(`${titleA} (${hoursA} h) vs ${titleB} (${hoursB} h)`);
// Redesign the multipurpose room (12 h) vs Carpentry workshop quote (5 h)And also when the key is not a valid identifier:
const config = { 'dark-mode': true, 'default-language': 'en' };
const { 'dark-mode': darkMode, 'default-language': language } = config;
console.log(darkMode, language); // true en
- Default values (and how to combine them with renaming)
Just as with arrays, and with the same rule: the default only applies when the value is undefined.
const partial = { title: 'Service the paper guillotine', estimatedHours: 2 };
const { title, assignee = 'unassigned', priority = 'medium', reviewer = null } = partial;
console.log(assignee); // 'unassigned' ← the property did not exist
console.log(priority); // 'medium'
console.log(reviewer); // nullconst withNull = { reviewer: null };
const { reviewer = 'no reviewer' } = withNull;
console.log(reviewer); // null ← null does NOT trigger the defaultThat behavior is deliberate: null is a considered value ("there is no reviewer and we know it"), whereas undefined means "nothing has been said". In the Nómada Tasks model, reviewer: null is valid data, so this behavior is the right one.
Renaming and default value combine, with the default placed after the new name:
const { estimatedHours: hours = 0, assignee: who = 'unassigned' } = { estimatedHours: 8 };
console.log(hours); // 8
console.log(who); // 'unassigned'The order reads like this: "take estimatedHours, call it hours, and if it does not come, let it be 0".
- Nested objects
The pattern can reproduce the object's whole structure:
const project = {
name: 'Nómada Tasks',
team: {
coordinator: 'Marta',
designer: 'Iván',
developer: 'Lucía'
},
totals: { hours: 48, open: 45 }
};
const { name, team: { coordinator, developer }, totals: { hours } } = project;
console.log(name); // 'Nómada Tasks'
console.log(coordinator); // 'Marta'
console.log(developer); // 'Lucía'
console.log(hours); // 48
// console.log(team); ✗ ReferenceErrorAn important detail that surprises everyone: when you write team: { coordinator }, the variable team is not created. The colon here means "go inside", not "store this". If you want both things, ask for them separately:
const { team, team: { coordinator: lead } } = project;
console.log(team.designer); // 'Iván'
console.log(lead); // 'Marta'And always protect the optional levels with a default value, or you will get a TypeError:
const noTeam = { name: 'Another project' };
// const { team: { coordinator } } = noTeam;
// ✗ TypeError: Cannot destructure property 'coordinator' of 'undefined'
const { team: { coordinator = 'unassigned' } = {} } = noTeam;
console.log(coordinator); // 'unassigned' ✓As in the previous lesson, the warning stands: if you need more than two levels, destructuring stops helping. A plain project.team.coordinator reads better.
- Destructuring in the parameters: the options object, now in the signature
Here is the most valuable use in the whole lesson. In 03-03 you designed createTask() taking an options object, and inside the function you had to pull out each field with ??. Now that is written straight into the signature.
// BEFORE (Module 3): the signature says nothing and the body fills up with ??
function createTaskOld(options) {
const title = options.title;
const assignee = options.assignee ?? 'unassigned';
const priority = options.priority ?? 'medium';
const status = options.status ?? 'pending';
const estimatedHours = options.estimatedHours ?? 1;
const reviewer = options.reviewer ?? null;
// ...
}
// NOW: the signature is the documentation
function createTask({
id,
title,
assignee = 'unassigned',
priority = 'medium',
status = 'pending',
tags = [],
estimatedHours = 1,
dueDate,
reviewer = null
}) {
if (typeof title !== 'string' || title.trim() === '') {
throw new Error('The title is required.'); // R1, the fail-fast from 02-05
}
if (!(estimatedHours > 0 && estimatedHours <= 40)) {
throw new Error('Hours must be between 0 and 40.'); // R3
}
return { id, title, assignee, priority, status,
tags, estimatedHours, dueDate, reviewer };
}
const newTask = createTask({
id: 7,
title: 'Service the bookbinding guillotine',
assignee: 'Lucía',
estimatedHours: 2,
dueDate: '2026-10-20',
tags: ['bookbinding', 'maintenance']
});
console.log(newTask);
// { id: 7, title: 'Service the bookbinding guillotine', assignee: 'Lucía',
// priority: 'medium', status: 'pending',
// tags: [ 'bookbinding', 'maintenance' ], estimatedHours: 2,
// dueDate: '2026-10-20', reviewer: null }Compare the two versions and notice what you gain:
| Options object "by hand" | Destructured in the signature | |
|---|---|---|
| Are the accepted fields visible? | No: you have to read the body | Yes, in the signature |
| Are the default values visible? | No | Yes |
| Setup lines | One per field | Zero |
| Typo risk | High (options.titel) |
The same, but concentrated in one place |
It works the same in the callbacks of the 04-05 methods, and there the improvement is enormous:
const backlog = [
{ id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván', priority: 'high', status: 'in-progress', estimatedHours: 12, dueDate: '2026-09-30' },
{ id: 2, title: 'Signage for the screen-printing workshop', assignee: 'Marta', priority: 'medium', status: 'pending', estimatedHours: 6, dueDate: '2026-10-15' },
{ id: 3, title: 'Update the bookings website', assignee: 'Lucía', priority: 'high', status: 'pending', estimatedHours: 14, dueDate: '2026-10-02' },
{ id: 4, title: 'Screen-printing ink inventory', assignee: 'Marta', priority: 'low', status: 'done', estimatedHours: 3, dueDate: '2026-09-12' },
{ id: 5, title: 'Bookbinding guide for residents', assignee: 'Iván', priority: 'medium', status: 'in-progress', estimatedHours: 8, dueDate: '2026-11-05' },
{ id: 6, title: 'Carpentry workshop quote', assignee: 'Iván', priority: 'high', status: 'pending', estimatedHours: 5, dueDate: '2026-09-05' }
];
// Without destructuring
const linesA = backlog.map((t) => `${t.title} · ${t.assignee} · ${t.estimatedHours} h`);
// Destructuring in the parameter: not a single "t." inside
const linesB = backlog.map(({ title, assignee, estimatedHours }) =>
`${title} · ${assignee} · ${estimatedHours} h`
);
console.log(linesB[5]); // 'Carpentry workshop quote · Iván · 5 h'And in a domain function such as isOverdue, the signature comes to declare exactly which fields it needs:
function isOverdue({ dueDate, status }, today) {
return dueDate < today && status !== 'done'; // R10
}
console.log(isOverdue(backlog[5], '2026-09-20')); // trueA judgment call: destructuring in the signature makes the dependency explicit, but you lose the whole object inside the function. If you also need the complete object (for example to return it), take it normally and destructure on the body's first line.
- The warning: destructuring
undefined and the = {} trick
undefined and the = {} trickIf you call createTask() with no arguments, the program blows up:
// createTask();
// ✗ TypeError: Cannot destructure property 'id' of 'undefined' as it is undefined.You cannot unpack what does not exist. The solution is to give a default value to the whole parameter:
function summarizeOptions({ order = 'date', limit = 10, includeDone = false } = {}) {
return `order=${order}, limit=${limit}, done=${includeDone}`;
}
console.log(summarizeOptions()); // order=date, limit=10, done=false
console.log(summarizeOptions({ limit: 3 })); // order=date, limit=3, done=false
console.log(summarizeOptions({ order: 'hours', includeDone: true }));
// order=hours, limit=10, done=trueThat trailing = {} reads as: "if nothing is passed to me, destructure an empty object", and then all the individual defaults come into play. Always write it when every field is optional; it is the difference between a function that can be called with no arguments and one that explodes.
The same problem appears outside functions, and the protection is ?? {}:
const response = { data: undefined };
const { total = 0 } = response.data ?? {};
console.log(total); // 0 ✓ no TypeError
- Property shorthand
A small companion you have been seeing since 04-06. When the variable's name matches the property's, there is no need to repeat it:
const id = 7;
const title = 'Service the paper guillotine';
const assignee = 'Lucía';
// The long form
const longTask = { id: id, title: title, assignee: assignee };
// Property shorthand: identical, but without the repetition
const task = { id, title, assignee };
console.log(task); // { id: 7, title: 'Service the paper guillotine', assignee: 'Lucía' }It is the inverse operation of destructuring: this one packs, that one unpacks. And they combine naturally in the "receive, transform, return" pattern:
function normalizeTask({ title, assignee, tags = [] }) {
const cleanTitle = title.trim();
const cleanTags = tags.map((t) => t.trim().toLowerCase()); // R9
return { title: cleanTitle, assignee, tags: cleanTags };
}
console.log(normalizeTask({ title: ' Carpentry workshop quote ',
assignee: 'Iván',
tags: [' Carpentry', 'PURCHASING'] }));
// { title: 'Carpentry workshop quote', assignee: 'Iván',
// tags: [ 'carpentry', 'purchasing' ] }Notice the bare assignee in the returned object: that is the shorthand, because the value does not change.
- Array spread
The ... operator has two faces. On the left-hand side of an assignment or in the parameters it collects (that is rest, which you already know). On the right-hand side, inside a literal or a call, it unfolds: that is spread.
const ivanTasks = ['Redesign the multipurpose room', 'Bookbinding guide'];
const martaTasks = ['Signage for the screen-printing workshop'];
// Copy
const copy = [...ivanTasks];
console.log(copy); // [ 'Redesign the multipurpose room', 'Bookbinding guide' ]
console.log(copy === ivanTasks); // false ← it is a new array
// Combine (it replaces concat)
const allTasks = [...ivanTasks, ...martaTasks];
console.log(allTasks.length); // 3
// Add elements at the front, in the middle or at the end
const withUrgent = ['URGENT', ...ivanTasks];
const inTheMiddle = [ivanTasks[0], 'Newly inserted task', ...ivanTasks.slice(1)];
console.log(inTheMiddle.length); // 3
// It works with any iterable
console.log([...'Nómada']); // [ 'N', 'ó', 'm', 'a', 'd', 'a' ]
console.log([...new Set([1, 1, 2])]); // [ 1, 2 ]That last example is the short idiom for deduplicating that we announced in 04-05: [...new Set(array)] is equivalent to Array.from(new Set(array)).
And this is the modern way of writing the immutable list operations you did in 04-03 with slice and concat:
const tags = ['carpentry', 'purchasing'];
const withNew = [...tags, 'urgent']; // add at the end
const withoutFirst = tags.slice(1); // remove the first
const inserted = [...tags.slice(0, 1), 'quotes', ...tags.slice(1)];
console.log(withNew); // [ 'carpentry', 'purchasing', 'urgent' ]
console.log(inserted); // [ 'carpentry', 'quotes', 'purchasing' ]
console.log(tags); // [ 'carpentry', 'purchasing' ] ✓ untouched
- Object spread: copying, combining, overwriting
The same thing, with braces. Object spread copies the own, enumerable properties into a new object.
const task = backlog[5];
// Copy
const copy = { ...task };
console.log(copy.title); // 'Carpentry workshop quote'
console.log(copy === task); // false ← a new object
// Combine two objects
const defaults = { priority: 'medium', status: 'pending', reviewer: null, tags: [] };
const formData = { id: 8, title: 'Buy black screen-printing ink', assignee: 'Marta' };
const newTask = { ...defaults, ...formData };
console.log(newTask);
// { priority: 'medium', status: 'pending', reviewer: null, tags: [],
// id: 8, title: 'Buy black screen-printing ink', assignee: 'Marta' }
// Overwrite one specific field
const finished = { ...task, status: 'done' };
console.log(finished.status); // 'done'
console.log(task.status); // 'pending' ✓ the original, untouchedThat last block is the pattern this lesson is named after, and we will develop it in section 13.
There is also Object.assign(target, ...sources), which did the same thing before spread existed. With one dangerous difference:
// Object.assign MUTATES the first argument
const target = { a: 1 };
Object.assign(target, { b: 2 });
console.log(target); // { a: 1, b: 2 } ← it has changed
// Unless you pass it an empty object as the target
const safe = Object.assign({}, { a: 1 }, { b: 2 }); // equivalent to { ...{a:1}, ...{b:2} }In new code, use spread: it does the same thing, it does not mutate anything by accident and it reads better.
- Order matters
When two objects have the same key, the last one wins. This rule is what makes spread useful, and what produces bugs when it is forgotten.
const base = { priority: 'medium', status: 'pending' };
const changes = { priority: 'high' };
console.log({ ...base, ...changes }); // { priority: 'high', status: 'pending' } ✓
console.log({ ...changes, ...base }); // { priority: 'medium', status: 'pending' } ✗On the second line, base is applied afterwards and stamps over the change. The mnemonic is straightforward: the default values go first, the real data afterwards.
function withDefaults(data) {
return { priority: 'medium', status: 'pending', reviewer: null, tags: [], ...data };
// ↑ defaults first ↑ the data wins
}
console.log(withDefaults({ title: 'Buy ink', priority: 'high' }));
// { priority: 'high', status: 'pending', reviewer: null, tags: [], title: 'Buy ink' }One detail to keep in mind: spread copies even the properties whose value is undefined, and those stamp over the default value.
console.log(withDefaults({ priority: undefined }));
// { priority: undefined, ... } ✗ the default has been lostIf that worries you, filter out the undefineds beforehand or use destructuring with default values, which does ignore them.
- Spread for passing arguments to a function
Spread unfolds an array into loose arguments, replacing apply (04-02):
const hours = [12, 6, 14, 3, 8, 5];
console.log(Math.max(...hours)); // 14
console.log(Math.min(...hours)); // 3
// Before ES2015 you had to write:
console.log(Math.max.apply(null, hours)); // 14It is the exact counterpart of the ...rest parameter you studied in 03-03:
function logAction(action, ...details) { // rest: it COLLECTS the arguments into an array
console.log(`${action}: ${details.join(' / ')}`);
}
const data = ['Iván', 'high', '5 h'];
logAction('New task', ...data); // spread: it UNFOLDS the array into arguments
// New task: Iván / high / 5 hWhere ... appears |
What it is called | What it does |
|---|---|---|
| In a function's parameters | Rest | Collects the leftover arguments into an array |
| In a destructuring pattern | Rest | Collects whatever is left |
Inside a [ ] or { } literal |
Spread | Unfolds the contents |
| In a function call | Spread | Unfolds the array into arguments |
Mnemonic: on the left of the = it collects, on the right it hands out.
- Rest in objects: removing fields
In an object pattern, ...rest collects into a new object every property you have not named. It is the idiomatic way of removing fields without delete:
const task = backlog[0];
const { id, ...withoutId } = task;
console.log(id); // 1
console.log(Object.keys(withoutId));
// [ 'title', 'assignee', 'priority', 'status', 'estimatedHours', 'dueDate' ]
// Removing several at once
const { id: _id, dueDate: _d, ...forTheReport } = task;
console.log(Object.keys(forTheReport));
// [ 'title', 'assignee', 'priority', 'status', 'estimatedHours' ]As with arrays, the rest must come last and it always produces an object (empty if nothing is left).
A very real case: preparing the data to be sent somewhere, without internal fields.
And compare it with delete, which you know from 04-01:
// ✗ It mutates the original object: anyone holding it loses the field
delete task.reviewer;
// ✓ It creates a new object without that field; the original is untouched
const { reviewer, ...withoutReviewer } = task;
- Immutable updates
Now for the central pattern. The question is: how do you change a task's status?
// Option A: MUTATE
function markDoneMutating(task) {
task.status = 'done';
return task;
}
// Option B: create a NEW task with the field changed
function markDone(task) {
return { ...task, status: 'done' };
}
const original = backlog[5];
const updated = markDone(original);
console.log(updated.status); // 'done'
console.log(original.status); // 'pending' ✓ the original is unchanged
console.log(updated === original); // falseflowchart LR
A["original task<br/>status: 'pending'"] -->|"{ ...task, status: 'done' }"| B["NEW task<br/>status: 'done'"]
A -.->|"still there,<br/>unchanged"| A2["original task<br/>status: 'pending'"]
The same pattern extends to the whole list, combining spread with map (04-04):
/** Returns a NEW backlog with task `id` updated. */
function updateTask(tasks, id, changes) {
return tasks.map((task) => (task.id === id ? { ...task, ...changes } : task));
}
const backlogV2 = updateTask(backlog, 6, { status: 'done', reviewer: 'Marta' });
console.log(backlogV2[5].status); // 'done'
console.log(backlog[5].status); // 'pending' ✓ untouched
console.log(backlogV2 === backlog); // false
console.log(backlogV2[0] === backlog[0]); // true ← the UNCHANGED ones are reusedThat last line is a valuable property: the tasks that do not change are literally the same object, so comparing with === tells you instantly what has changed without walking anything. It is the foundation of the rendering optimizations you will see in Module 10.
And the full set of immutable operations on the backlog:
const add = (tasks, task) => [...tasks, task];
const remove = (tasks, id) => tasks.filter((t) => t.id !== id);
const update = (tasks, id, changes) =>
tasks.map((t) => (t.id === id ? { ...t, ...changes } : t));
const reorder = (tasks, cmp) => tasks.toSorted(cmp);
const v1 = add(backlog, { id: 7, title: 'Service the paper guillotine', assignee: 'Lucía',
priority: 'medium', status: 'pending', estimatedHours: 2,
dueDate: '2026-10-20' });
const v2 = update(v1, 6, { status: 'done' });
const v3 = remove(v2, 4);
console.log(backlog.length, v1.length, v2.length, v3.length); // 6 7 7 6Four versions of the backlog coexisting, and none has destroyed the previous one. Undoing a change is as simple as keeping the earlier version.
- Why this underpins all state work
Creating new objects instead of modifying the existing ones may look like a whim. It is not, and it is worth understanding why before you reach Module 10.
| Advantage | What it means in practice |
|---|---|
| Detecting changes is trivial | previous === next answers instantly, with no field-by-field comparison |
| History for free | By keeping the earlier versions you get undo/redo with no effort |
| No action at a distance | Nobody modifies, by surprise, an object you had stored away |
| Predictable debugging | You can inspect the state at every step because it is not overwritten |
| Safe concurrency | No data changes under the feet of an operation in progress (Module 5) |
Modern frameworks are built on this idea: in React, updating the state means returning a new object, not modifying the existing one; in Redux, the reducer is literally a function that takes the state and returns a new one —and yes, it is called a reducer for the same reason as reduce. You will see it in detail in Why Frameworks Exist and Redux. What matters now is that the syntax you have just learned, { ...task, status: 'done' }, is exactly the one you will write there.
The cost, to be honest: creating copies consumes memory and time. With six tasks it is irrelevant; with huge lists there are specific techniques, and that is Module 9's subject. The practical recommendation stays the same: immutable by default, and mutate only inside a function, on data that function created and nobody else can see —as you did in the workload ranking's reduce in 04-05.
- The critical limit: spread copies only the surface
And here comes the problem this lesson cannot solve. Try this:
const task = {
id: 1,
title: 'Redesign the multipurpose room',
tags: ['space', 'design']
};
const copy = { ...task };
// Changing a simple field: perfect, they are independent
copy.title = 'Another title';
console.log(task.title); // 'Redesign the multipurpose room' ✓
// Changing something INSIDE the array: disaster
copy.tags.push('furniture');
console.log(task.tags); // [ 'space', 'design', 'furniture' ] ✗ the original has changed!
console.log(copy.tags === task.tags); // true ← it is the SAME arrayThe explanation comes from 01-05: primitive values are copied; objects and arrays are copied by reference. Spread copies the values of the properties, and the value of tags is a reference. The result: two different objects pointing at the same array.
flowchart TD
A["task<br/>{ id, title, tags }"] --> C["array<br/>['space', 'design']"]
B["copy<br/>{ id, title, tags }"] --> C
C -.->|"a push from either one<br/>affects both"| C
This is called a shallow copy: it copies one level. For a task's primitive fields —id, title, status, estimatedHours, dueDate— that is enough. For tags and for nested subtasks, it is not.
There is a manual workaround, which consists of spreading the inner level too:
const goodCopy = { ...task, tags: [...task.tags] };
goodCopy.tags.push('furniture');
console.log(task.tags); // [ 'space', 'design' ] ✓ safeIt works, but it requires knowing and hand-writing every nested level. With the subtask tree from 03-07, which can be any depth, it is simply unworkable. You need a deep copy, and the tools for getting one —structuredClone, the old JSON trick and its limitations— are the content of JSON and Copying Objects.
Common Mistakes and Tips
1. Reading the renaming backwards. { title: t } means "the title property will be called t", not the other way round.
2. Believing that destructuring a nested object creates the intermediate variable. const { team: { lead } } = p; does not create team.
3. Forgetting the = {} on a destructured parameter. Calling the function with no arguments throws a TypeError.
4. Putting the data before the default values when combining with spread. The defaults go first; what wins is whatever comes last.
5. Using Object.assign(obj, changes) thinking it copies. It mutates the first argument. Use { ...obj, ...changes }.
6. Putting the rest in the middle. const { ...rest, id } = task; is a SyntaxError.
7. Trusting that spread makes a complete copy. It copies a single level: nested arrays and objects are shared.
8. Destructuring twenty fields just because. If the function uses three, ask for three. A signature with fifteen names is worse than (task).
Professional tip. Use destructuring to declare what you need and spread to declare what you change. function isOverdue({ dueDate, status }, today) says, with no comments, that rule R10 depends only on those two fields; and { ...task, status: 'done' } says, with no comments, that the only thing changing is the status. That declarative style is half the value of this syntax; the other half is not breaking anything along the way.
Exercises
Exercise 1 — A signature that documents itself. Rewrite these two functions by destructuring in the parameters, with default values where they make sense, and so that they can be called with no arguments without throwing:
function taskSummary(task) {
return task.title + ' (' + task.assignee + ', ' + task.estimatedHours + ' h)';
}
function filterBacklog(tasks, options) {
const status = options.status;
const minHours = options.minHours || 0;
return tasks.filter((t) => (!status || t.status === status) && t.estimatedHours >= minHours);
}Exercise 2 — Status changes with no collateral damage. Write these three functions, all immutable:
changeStatus(tasks, id, newStatus)— returns a new backlog with that task in the given status; if the status is not valid, it throws anError.assignReviewer(tasks, id, reviewer)— the same, but changing the reviewer.withoutInternalFields(task)— returns the task withoutreviewerortags, using rest.
After each one, check that the original backlog has not changed.
Exercise 3 — Merging templates. Taller Nómada has task templates by kind of job. Write fromTemplate(template, data) combining three layers: some global default values, the template and the specific data, in the correct order of precedence. Use this template and this data:
const SCREEN_PRINTING_TEMPLATE = { priority: 'medium', tags: ['screen-printing'], estimatedHours: 4, reviewer: 'Marta' };
const data = { id: 9, title: 'Autumn course T-shirts', assignee: 'Iván', estimatedHours: 6 };Also check that modifying the result's tags does not modify the template's, and explain what you had to do to achieve that.
Solutions
Exercise 1
function taskSummary({ title = 'untitled', assignee = 'unassigned', estimatedHours = 0 } = {}) {
return `${title} (${assignee}, ${estimatedHours} h)`;
}
function filterBacklog(tasks = [], { status, minHours = 0 } = {}) {
return tasks.filter((t) => (!status || t.status === status) && t.estimatedHours >= minHours);
}
console.log(taskSummary(backlog[2]));
// 'Update the bookings website (Lucía, 14 h)'
console.log(taskSummary());
// 'untitled (unassigned, 0 h)'
console.log(filterBacklog(backlog, { status: 'pending' }).length); // 3
console.log(filterBacklog(backlog, { minHours: 10 }).map((t) => t.id)); // [ 1, 3 ]
console.log(filterBacklog(backlog).length); // 6
console.log(filterBacklog().length); // 0The two = {} are the key to keeping the last calls from blowing up. Notice too that status has no default value: here undefined means "do not filter by status", and that is exactly what !status checks. Adding a default would have destroyed that semantics.
Exercise 2
const VALID_STATUSES = ['pending', 'in-progress', 'done'];
function changeStatus(tasks, id, newStatus) {
if (!VALID_STATUSES.includes(newStatus)) {
throw new Error(`Invalid status: ${newStatus}`);
}
if (!tasks.some((t) => t.id === id)) {
throw new Error(`There is no task with id ${id}.`);
}
return tasks.map((t) => (t.id === id ? { ...t, status: newStatus } : t));
}
function assignReviewer(tasks, id, reviewer) {
return tasks.map((t) => (t.id === id ? { ...t, reviewer } : t));
}
function withoutInternalFields(task) {
const { reviewer, tags, ...publicFields } = task;
return publicFields;
}
const backlogV2 = changeStatus(backlog, 6, 'done');
console.log(backlogV2[5].status); // 'done'
console.log(backlog[5].status); // 'pending' ✓
const backlogV3 = assignReviewer(backlogV2, 2, 'Lucía');
console.log(backlogV3[1].reviewer); // 'Lucía'
console.log(backlogV2[1].reviewer); // undefined ✓ the previous version was not touched
console.log(Object.keys(withoutInternalFields(backlog[0])));
// [ 'id', 'title', 'assignee', 'priority', 'status', 'estimatedHours', 'dueDate' ]
try {
changeStatus(backlog, 1, 'finished');
} catch (error) {
console.log(error.message); // 'Invalid status: finished'
}Three details. In assignReviewer, { ...t, reviewer } uses the property shorthand: the variable has the same name as the field. The validations come before anything is built, following the fail-fast principle from 02-05. And some (04-05) is the correct way of checking existence without walking the whole list.
Exercise 3
const DEFAULTS = {
priority: 'medium',
status: 'pending',
tags: [],
estimatedHours: 1,
reviewer: null
};
function fromTemplate(template = {}, data = {}) {
return {
...DEFAULTS, // 1. the most generic layer
...template, // 2. the template overrides the defaults
...data, // 3. the specific data rules
tags: [ // 4. tags accumulate, they do not override
...new Set([...(template.tags ?? []), ...(data.tags ?? [])])
]
};
}
const SCREEN_PRINTING_TEMPLATE = { priority: 'medium', tags: ['screen-printing'], estimatedHours: 4, reviewer: 'Marta' };
const data = { id: 9, title: 'Autumn course T-shirts', assignee: 'Iván', estimatedHours: 6 };
const newTask = fromTemplate(SCREEN_PRINTING_TEMPLATE, data);
console.log(newTask);
// { priority: 'medium', status: 'pending', estimatedHours: 6, reviewer: 'Marta',
// id: 9, title: 'Autumn course T-shirts', assignee: 'Iván',
// tags: [ 'screen-printing' ] }
newTask.tags.push('t-shirts');
console.log(SCREEN_PRINTING_TEMPLATE.tags); // [ 'screen-printing' ] ✓ untouchedThe heart of the exercise is point 4. Had the function stopped at { ...DEFAULTS, ...template, ...data }, the result's tags field would be the same array as SCREEN_PRINTING_TEMPLATE's, and the final push would have contaminated the template for every future task: the shallow-copy problem from section 15. The solution was to explicitly spread the inner array with [...] —creating a new one— and, while we were at it, to use a Set so tags arriving via both routes are not duplicated. That manual, level-by-level work is exactly what the next lesson automates.
Notice too that estimatedHours ends up as 6 and not 4: the specific data comes last and wins, just as the order rule says.
Conclusion
You have the full syntax. Object destructuring matches by name: you can rename with { title: t } —always reading it as "property on the left, variable on the right"—, give default values that only trigger with undefined, combine both things, and go into nested objects remembering that the intermediate variable is not created. Its best use is in parameters: the options object that in 03-03 was taken apart line by line inside the body is now declared in the signature, with createTask({ ... }) and isOverdue({ dueDate, status }, today) documenting themselves, and always with the = {} that avoids the TypeError when calling with no arguments.
On the other side, the ... operator in its two faces: rest when it collects (leftover parameters, unnamed properties, const { id, ...rest } = task to remove fields without delete) and spread when it hands out (copying, combining, overwriting, inserting in the middle, passing arguments with Math.max(...hours)). With the rule that governs it all: when combining, the last one wins, so the default values go first and the real data afterwards. And with that you have written the pattern that underpins modern development: the immutable update { ...task, status: 'done' } and its version over lists, tasks.map((t) => t.id === id ? { ...t, ...changes } : t), which produces a new backlog while keeping the unchanged tasks by reference.
But you have ended up hitting a wall. Spread makes a shallow copy: changing copy.title is safe, but copy.tags.push(...) also modifies the original, because both objects point at the same array. There is a manual workaround, { ...task, tags: [...task.tags] }, and it works as long as you know every nested level; with a subtask tree of unknown depth, no workaround will do. You need a genuine deep copy. That, together with the other big question you have not yet answered —how the backlog becomes text so it can be saved and loaded back— is the content of JSON and Copying Objects, the module's last lesson.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
