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

  1. Object destructuring: the name rules
  2. Renaming properties
  3. Default values (and how to combine them with renaming)
  4. Nested objects
  5. Destructuring in the parameters: the options object, now in the signature
  6. The warning: destructuring undefined and the = {} trick
  7. Property shorthand
  8. Array spread
  9. Object spread: copying, combining, overwriting
  10. Order matters
  11. Spread for passing arguments to a function
  12. Rest in objects: removing fields
  13. Immutable updates
  14. Why this underpins all state work
  15. The critical limit: spread copies only the surface
  16. Common Mistakes and Tips
  17. Exercises
  18. Conclusion

  1. 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 5

The 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:

const { titel } = task;
console.log(titel);      // undefined   ← a typo, no error

And if the property does not exist, it does not fail either:

const { comments } = task;
console.log(comments);   // undefined

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

  1. 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 exist

It 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

  1. 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);   // null
const withNull = { reviewer: null };
const { reviewer = 'no reviewer' } = withNull;
console.log(reviewer);   // null   ← null does NOT trigger the default

That 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".

  1. 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);       ✗ ReferenceError

An 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.

  1. 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'));   // true

A 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.

  1. The warning: destructuring undefined and the = {} trick

If 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=true

That 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

  1. 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.

  1. 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

  1. 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, untouched

That 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.

  1. 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 lost

If that worries you, filter out the undefineds beforehand or use destructuring with default values, which does ignore them.

  1. 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));   // 14

It 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 h
Where ... 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.

  1. 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.

function forExport(task) {
  const { reviewer, tags, ...publicFields } = task;
  return publicFields;
}

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;

  1. 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);  // false
flowchart 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 reused

That 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 6

Four versions of the backlog coexisting, and none has destroyed the previous one. Undoing a change is as simple as keeping the earlier version.

  1. 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.

  1. 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 array

The 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' ]   ✓ safe

It 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:

  1. changeStatus(tasks, id, newStatus) — returns a new backlog with that task in the given status; if the status is not valid, it throws an Error.
  2. assignReviewer(tasks, id, reviewer) — the same, but changing the reviewer.
  3. withoutInternalFields(task) — returns the task without reviewer or tags, 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);                                      // 0

The 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' ]   ✓ untouched

The 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

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved