The previous lesson ended by hitting a wall: spread copies only the first level, so copy.tags.push('furniture') also modifies the original. And another equally important question was left open: the Nómada Tasks backlog lives in memory and disappears the moment the tab is closed; to save it or send it you have to turn it into text. Both questions are answered with the same tools, and that is this lesson's subject: JSON as an interchange format, with JSON.stringify and JSON.parse and everything that gets lost on the round trip; and copies, shallow and deep, with structuredClone as the modern answer and Object.freeze as a safety net. It is the module's last lesson, so we will also close the circle: you will see what you are still missing to stop every task from repeating the same methods over and over.

Contents

  1. What JSON is
  2. JSON is not JavaScript: the table of differences
  3. JSON.stringify: converting to text
  4. Formatting with the third parameter
  5. Filtering with the replacer and with the array of keys
  6. toJSON: letting an object decide how it serializes
  7. What gets lost on the round trip
  8. JSON.parse: turning it back into an object
  9. The reviver: transforming while reading
  10. JSON.parse always inside try/catch
  11. Shallow copy: { ...obj } and Object.assign
  12. Deep copy: structuredClone and the JSON trick
  13. The copying table
  14. Shared references: the backlog bug
  15. Object.freeze and its shallowness
  16. Worked example: saving and loading the backlog
  17. Common Mistakes and Tips
  18. Exercises
  19. Conclusion

  1. What JSON is

JSON (JavaScript Object Notation) is a text format for representing structured data. It was born out of JavaScript's object syntax, but today it is an independent standard understood by practically every language: when a web application asks a server for data (Module 7), what travels over the network is almost always JSON.

A JavaScript object and its JSON representation, side by side:

// A JavaScript object, a live value in memory
const task = { id: 6, title: 'Carpentry workshop quote', estimatedHours: 5 };
{"id":6,"title":"Carpentry workshop quote","estimatedHours":5}

That second block is a string. It has no properties, you cannot do task.title on it, it does not occupy memory as an object: it is a sequence of characters that describes an object. The difference looks obvious and it is the source of the number one misunderstanding about JSON.

JSON is text. An object is "serialized" to JSON so it can be saved, sent or compared; and it is "parsed" back to work with it again.

The types JSON accepts are only six: string, number, boolean, null, object and array. Nothing else.

  1. JSON is not JavaScript: the table of differences

The syntax looks similar, but JSON is far stricter. These are the differences that cause real errors:

Aspect JavaScript object JSON
Quotes on keys Optional Mandatory, and double: {"id": 6}
Quotes on strings Single or double Double only
Trailing comma Allowed Forbidden: {"a":1,} is an error
Comments // and /* */ They do not exist
undefined Valid Does not exist
Functions Valid They do not exist
Dates A Date object They do not exist: stored as text
NaN, Infinity Valid They do not exist
Computed keys { [k]: v } No
// Valid JSON
'{"id": 6, "title": "Carpentry workshop quote", "reviewer": null}'

// Invalid JSON (even though it would be perfectly valid JavaScript)
"{id: 6}"                         // ✗ unquoted key
"{'id': 6}"                       // ✗ single quotes
'{"id": 6,}'                      // ✗ trailing comma
'{"id": 6, "status": undefined}'  // ✗ undefined does not exist in JSON

Bear in mind, too, that dueDate is a string in the Nómada Tasks model. That Module 1 design decision, which may have looked arbitrary back then, makes complete sense here: since JSON has no native dates, storing '2026-09-05' means the value survives the round trip intact, with no conversions and no surprises.

  1. JSON.stringify: converting to text

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 text = JSON.stringify(task);
console.log(typeof text);   // 'string'
console.log(text);
// {"id":6,"title":"Carpentry workshop quote","assignee":"Iván","priority":"high","status":"pending","tags":["carpentry","purchasing"],"estimatedHours":5,"dueDate":"2026-09-05","reviewer":"Marta"}

It works with any value, not just objects:

console.log(JSON.stringify(42));            // '42'
console.log(JSON.stringify('hello'));       // '"hello"'   ← careful: with quotes inside
console.log(JSON.stringify(true));          // 'true'
console.log(JSON.stringify(null));          // 'null'
console.log(JSON.stringify([1, 2, 3]));     // '[1,2,3]'
console.log(JSON.stringify(undefined));     // undefined  ← not a string!

That last line is surprising: JSON.stringify(undefined) does not return 'undefined', it returns the value undefined. It is a detail that can blow up a localStorage.setItem in Module 7.

  1. Formatting with the third parameter

The third parameter adds line breaks and indentation. It is what you want when a person is going to read the JSON:

console.log(JSON.stringify(task, null, 2));
{
  "id": 6,
  "title": "Carpentry workshop quote",
  "assignee": "Iván",
  "priority": "high",
  "status": "pending",
  "tags": [
    "carpentry",
    "purchasing"
  ],
  "estimatedHours": 5,
  "dueDate": "2026-09-05",
  "reviewer": "Marta"
}

The null in the middle is the second parameter (the replacer, next section), which we are not using here. The 2 is the number of spaces of indentation; it also accepts a string, such as '\t' for tabs.

A very useful debugging trick. console.log(object) sometimes trims large objects or shows [Object] at deeper levels. console.log(JSON.stringify(object, null, 2)) shows the whole thing, readably. You will use it constantly in Module 8.

To save or send it, on the other hand, do not format: the indentation multiplies the size of the text without giving a machine anything.

  1. Filtering with the replacer and with the array of keys

The second parameter decides what gets serialized. It accepts two forms.

An array of keys, the simplest: only those properties pass the filter.

console.log(JSON.stringify(task, ['id', 'title', 'status']));
// {"id":6,"title":"Carpentry workshop quote","status":"pending"}

A replacer function, which is called for each key-value pair and decides what to return. If it returns undefined, the property is omitted.

const publicJson = JSON.stringify(task, (key, value) => {
  if (key === 'reviewer') return undefined;         // omitted
  if (key === 'title') return value.toUpperCase();  // transformed
  return value;                                     // the rest, as is
}, 2);

console.log(publicJson.includes('reviewer'));   // false
console.log(publicJson.includes('CARPENTRY WORKSHOP QUOTE'));   // true

Two details about the replacer: it is called for the root too, with the key '' and the whole object as the value; and remember that in an array the keys are the indexes as strings. If you forget the final return value, everything turns into undefined and the result is empty.

A real application: exporting the backlog without the internal fields.

const PUBLIC_FIELDS = ['id', 'title', 'assignee', 'priority', 'status', 'estimatedHours', 'dueDate'];
const exportable = JSON.stringify([task], PUBLIC_FIELDS, 2);
console.log(exportable.includes('reviewer'));   // false
console.log(exportable.includes('tags'));       // false

  1. toJSON: letting an object decide how it serializes

If an object has a method called toJSON, JSON.stringify calls it and serializes whatever it returns instead of the object. It is the way to control the representation from the inside:

const board = {
  name: 'Taller Nómada',
  today: '2026-09-20',
  tasks: [task],
  internalCache: { computedAt: 1758300000000 },     // internal data we do not want to export

  toJSON() {
    return {
      name: this.name,
      exportedOn: this.today,
      totalTasks: this.tasks.length,
      tasks: this.tasks
    };
  }
};

console.log(JSON.stringify(board, null, 2).includes('internalCache'));   // false
console.log(JSON.parse(JSON.stringify(board)).totalTasks);              // 1

Date does the same thing internally: it has its own toJSON that returns the date in ISO format, and that is why a date serializes as text.

console.log(JSON.stringify({ createdAt: new Date('2026-09-20T10:30:00Z') }));
// {"createdAt":"2026-09-20T10:30:00.000Z"}

  1. What gets lost on the round trip

This is the section to memorize. Serializing and parsing back does not always return what went in:

const original = {
  id: 7,
  title: 'Service the paper guillotine',
  reviewer: undefined,                      // undefined
  compute: () => 42,                        // a function
  createdAt: new Date('2026-09-20'),        // a Date
  progress: NaN,                            // NaN
  limit: Infinity,                          // Infinity
  tags: ['bookbinding'],
  internal: Symbol('x')                     // a Symbol
};

const roundTrip = JSON.parse(JSON.stringify(original));
console.log(roundTrip);
// {
//   id: 7,
//   title: 'Service the paper guillotine',
//   createdAt: '2026-09-20T00:00:00.000Z',  ← now it is a STRING
//   progress: null,                          ← NaN became null
//   limit: null,                             ← so did Infinity
//   tags: [ 'bookbinding' ]
// }
// reviewer, compute and internal have DISAPPEARED

The full table:

Original value After stringify + parse
string, finite number, boolean, null The same ✓
Object, array The same (but new objects) ✓
undefined in a property The property disappears
undefined in an array Becomes null
Function The property disappears
Symbol The property disappears
Date Becomes an ISO string
NaN, Infinity, -Infinity Become null
Map, Set Become {} (everything is lost!)
Circular reference Throws a TypeError

The last two deserve special attention:

console.log(JSON.stringify(new Set([1, 2, 3])));           // '{}'   ✗ empty
console.log(JSON.stringify(new Map([['a', 1]])));          // '{}'   ✗ empty

const a = { name: 'a' };
a.self = a;                                                 // a circular reference
// JSON.stringify(a);
// ✗ TypeError: Converting circular structure to JSON

A circular reference is an object that contains itself, directly or indirectly. It happens more often than you would think: for example, if you add to each subtask a parent field pointing at the task containing it, the tree stops being serializable with JSON.

Good news for Nómada Tasks: the canonical model is made up only of numbers, strings, null, arrays of strings and nested objects. It survives the round trip intact. That is no accident: it is a direct consequence of the Module 1 design decisions.

  1. JSON.parse: turning it back into an object

const text = '{"id":6,"title":"Carpentry workshop quote","estimatedHours":5}';
const task = JSON.parse(text);

console.log(typeof task);              // 'object'
console.log(task.title);               // 'Carpentry workshop quote'
console.log(task.estimatedHours + 1);  // 6   ← it really is a number

And with arrays of objects, which is how the backlog will arrive:

const backlog = JSON.parse('[{"id":1,"estimatedHours":12},{"id":2,"estimatedHours":6}]');
console.log(Array.isArray(backlog));    // true
console.log(backlog.reduce((acc, t) => acc + t.estimatedHours, 0));   // 18

A crucial point: JSON.parse produces completely new objects. There is no relationship whatsoever with the original objects, even if the contents are identical. It is exactly that property we will use to make deep copies.

  1. The reviver: transforming while reading

JSON.parse's second parameter is a function called for each key-value pair as it reads, which lets you transform the data on the spot. The classic use is rebuilding dates:

const saved = '{"title":"Service the paper guillotine","createdAt":"2026-09-20T10:30:00.000Z"}';

const loaded = JSON.parse(saved, (key, value) => {
  if (key === 'createdAt' && typeof value === 'string') return new Date(value);
  return value;
});

console.log(loaded.createdAt instanceof Date);   // true
console.log(loaded.createdAt.getFullYear());     // 2026

In Nómada Tasks the reviver is useful for normalizing data arriving from outside, applying the business rules right at the door:

function parseBacklog(text) {
  return JSON.parse(text, (key, value) => {
    if (key === 'tags' && Array.isArray(value)) {
      return value.map((t) => t.trim().toLowerCase());      // R9
    }
    if (key === 'estimatedHours' && typeof value === 'string') {
      return Number(value);
    }
    return value;
  });
}

const dirty = '[{"id":1,"tags":[" Space ","DESIGN"],"estimatedHours":"12"}]';
console.log(parseBacklog(dirty));
// [ { id: 1, tags: [ 'space', 'design' ], estimatedHours: 12 } ]

Like the replacer, the reviver must end with return value for everything it does not transform; if you forget, the result will be undefined.

  1. JSON.parse always inside try/catch

JSON.parse throws an exception if the text is not valid JSON. And the text almost always comes from outside: from a server, from a file, from the browser's storage. It is therefore a perfect candidate for the try/catch from 02-05.

// ✗ Unprotected: one piece of corrupt data takes the application down
// const data = JSON.parse(textFromTheServer);

// ✓ Protected, with a fallback value
function safeParse(text, fallback = null) {
  try {
    return JSON.parse(text);
  } catch (error) {
    console.warn(`Invalid JSON: ${error.message}`);
    return fallback;
  }
}

console.log(safeParse('{"id":6}'));         // { id: 6 }
console.log(safeParse('{id: 6}', []));      // []   (with a console warning)
console.log(safeParse('', []));             // []
console.log(safeParse(null, []));           // null ← the value null becomes the text 'null', which is valid JSON

The fuller version tells a syntax error apart from valid but unexpected content, using the class ValidationError extends Error you met in 02-05:

class DataError extends Error {
  constructor(message) {
    super(message);
    this.name = 'DataError';
  }
}

function loadBacklog(text) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new DataError(`The file does not contain valid JSON: ${error.message}`);
  }
  if (!Array.isArray(data)) {
    throw new DataError('An array of tasks was expected.');
  }
  if (!data.every((t) => typeof t.id === 'number' && typeof t.title === 'string')) {
    throw new DataError('Some task has no valid id or title.');
  }
  return data;
}

try {
  loadBacklog('{"id":1}');
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
  // DataError: An array of tasks was expected.
}

Notice the order: parse first (which can fail on syntax), then validate the shape of the data. Parsing correctly does not guarantee that the content is what you expect.

  1. Shallow copy: { ...obj } and Object.assign

Let us go back to the wall 04-07 ended on. These are the two ways of making a shallow copy, and both have the same limit:

const task = {
  id: 1,
  title: 'Redesign the multipurpose room',
  tags: ['space', 'design']
};

const copyA = { ...task };
const copyB = Object.assign({}, task);

console.log(copyA.tags === task.tags);   // true  ✗ the same array
console.log(copyB.tags === task.tags);   // true  ✗ the same array

copyA.title = 'Changed';           // ✓ safe: a primitive string
copyA.tags.push('furniture');      // ✗ it also affects task and copyB

console.log(task.title);           // 'Redesign the multipurpose room'  ✓
console.log(task.tags);            // [ 'space', 'design', 'furniture' ]  ✗

For arrays, the equivalent shallow copies are [...array], array.slice() and Array.from(array). All three copy the list, but if the elements are objects, they are still the same objects:

const list = [{ id: 1, title: 'A' }, { id: 2, title: 'B' }];
const copy = [...list];

copy.push({ id: 3, title: 'C' });
console.log(list.length);        // 2   ✓ the list is independent

copy[0].title = 'MODIFIED';
console.log(list[0].title);      // 'MODIFIED'   ✗ the objects are not

  1. Deep copy: structuredClone and the JSON trick

A deep copy duplicates the object and everything it contains, to any depth. There are two practical ways.

structuredClone(value) is the modern answer: a global function, built into the language and into browsers.

const task = {
  id: 1,
  title: 'Redesign the multipurpose room',
  tags: ['space', 'design'],
  subtasks: [
    { id: 11, title: 'Measure and draw up the floor plan', estimatedHours: 3, subtasks: [] },
    { id: 12, title: 'Choose the furniture', estimatedHours: 0, subtasks: [
      { id: 121, title: 'Request quotes', estimatedHours: 2, subtasks: [] }
    ] }
  ]
};

const clone = structuredClone(task);

clone.tags.push('furniture');
clone.subtasks[1].subtasks[0].title = 'CHANGED';

console.log(task.tags);                             // [ 'space', 'design' ]  ✓
console.log(task.subtasks[1].subtasks[0].title);    // 'Request quotes'       ✓
console.log(clone.subtasks[1].subtasks[0].title);   // 'CHANGED'

Its virtues: it preserves Date, Map, Set, RegExp and binary data, and it handles circular references without throwing. Its limit: it cannot clone functions; if the object has a method, it throws a DataCloneError.

The JSON trick, which you will see in an enormous amount of code predating structuredClone:

const jsonClone = JSON.parse(JSON.stringify(task));
console.log(jsonClone.subtasks[1].subtasks[0].title);   // 'Request quotes'

It works, and it is easy to remember, but it drags along every loss from section 7: undefined, functions and symbols disappear; dates turn into text; NaN and Infinity turn into null; Map and Set are emptied; and a circular reference throws a TypeError. It is also slower, because it builds a complete intermediate string.

When to use each one:

  • structuredClone by default, whenever your environment has it.
  • JSON.parse(JSON.stringify(...)) only if you know your data is "pure JSON" —exactly the case of the canonical Nómada Tasks backlog— or if you work in an older environment.
  • A shallow copy when the object has no nested levels, or when the nested part is read-only and nobody is going to mutate it.

  1. The copying table

Technique Depth Preserves Date/Map/Set Functions Circular refs Speed
{ ...obj } Shallow Yes (by reference) Yes (by reference) Yes Very fast
Object.assign({}, obj) Shallow Yes (by reference) Yes (by reference) Yes Very fast
[...arr], arr.slice() Shallow Very fast
structuredClone(obj) Deep Yes No (it throws) Yes Fast
JSON.parse(JSON.stringify(obj)) Deep No No (they are lost) No (it throws) Slow
A manual level-by-level copy Whatever you write Whatever you write Yes Careful The fastest if it is small

And the manual copy, the one from section 15 of 04-07, which is still perfectly valid when the structure is known and small:

const manualCopy = {
  ...task,
  tags: [...task.tags],
  subtasks: task.subtasks.map((s) => ({ ...s, subtasks: [...s.subtasks] }))
};

Notice that it only reaches the second level of subtasks: if tomorrow Iván adds a third, this copy goes back to sharing references. That is exactly why automatic deep copies exist.

  1. Shared references: the backlog bug

Let us look at the problem through a realistic case, the one that would happen to Lucía in the application.

const backlog = [
  { id: 1, title: 'Redesign the multipurpose room', tags: ['space', 'design'], estimatedHours: 12 },
  { id: 2, title: 'Signage for the screen-printing workshop', tags: ['screen-printing'], estimatedHours: 6 }
];

// "I'll save a backup before touching anything"
const backup = [...backlog];                   // ✗ a SHALLOW copy

// The user edits a task
backlog[0].title = 'Redesign the big room';
backlog[0].tags.push('works');

// "Undo": restore from the backup
console.log(backup[0].title);       // 'Redesign the big room'   ✗ the backup changed TOO
console.log(backup[0].tags);        // [ 'space', 'design', 'works' ]  ✗

The backup was useless: [...backlog] created a new array, but with the same objects inside.

flowchart TD
    A["backlog<br/>(array)"] --> T1["task 1<br/>{ title, tags }"]
    A --> T2["task 2"]
    B["backup<br/>(NEW array)"] --> T1
    B --> T2
    T1 --> E["tags<br/>['space','design']"]
    style T1 fill:#f9d5d5,stroke:#c66

The solution, with a deep copy:

const backlog2 = [
  { id: 1, title: 'Redesign the multipurpose room', tags: ['space', 'design'], estimatedHours: 12 }
];

const goodBackup = structuredClone(backlog2);

backlog2[0].title = 'Redesign the big room';
backlog2[0].tags.push('works');

console.log(goodBackup[0].title);       // 'Redesign the multipurpose room'  ✓
console.log(goodBackup[0].tags);        // [ 'space', 'design' ]             ✓

And a decision rule so you never get it wrong:

What you are going to do The copy you need
Change only primitive fields (status, title) Shallow: { ...task, status: 'done' }
Change an inner array or object Shallow + spread that level: { ...t, tags: [...t.tags] }
Save a backup, a history, or send it elsewhere Deep: structuredClone
Send it over the network or store it as text Serialize with JSON.stringify

  1. Object.freeze and its shallowness

If you want to genuinely prevent an object from being modified, Object.freeze freezes it:

'use strict';

const WEIGHTS = Object.freeze({ high: 3, medium: 2, low: 1 });

console.log(Object.isFrozen(WEIGHTS));   // true

// WEIGHTS.high = 99;
// ✗ TypeError: Cannot assign to read only property 'high' of object (in strict mode)
// Without 'use strict', the assignment fails SILENTLY: nothing changes and nothing warns you.

Freezing prevents three things: modifying existing properties, adding new ones and deleting them. It is the right tool for the project's configuration constantsWEIGHTS, BADGES, VALID_STATUSES— because const only protects the variable, not the contents (04-01).

But Object.freeze is shallow, exactly like spread:

const template = Object.freeze({
  priority: 'medium',
  tags: ['screen-printing']
});

// template.priority = 'high';       // ✗ TypeError: well protected
template.tags.push('urgent');        // ✓ allowed!
console.log(template.tags);          // [ 'screen-printing', 'urgent' ]   ✗

To freeze deeply you have to walk the structure, and here the recursion from 03-07 comes back:

function deepFreeze(object) {
  for (const value of Object.values(object)) {
    if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return Object.freeze(object);
}

const safeTemplate = deepFreeze({ priority: 'medium', tags: ['screen-printing'] });

try {
  safeTemplate.tags.push('urgent');
} catch (error) {
  console.log(error.name);      // 'TypeError'
}
console.log(safeTemplate.tags);   // [ 'screen-printing' ]   ✓

The !Object.isFrozen(value) check is not decoration: it avoids an infinite loop if there are circular references. A usage tip: freeze the constants and the configuration data, not the state the application modifies constantly; for that, the immutable-update discipline of 04-07 is a better tool than prohibition.

  1. Worked example: saving and loading the backlog

Let us put it all together in the piece Nómada Tasks will need in Module 7: turning the backlog into text so it can be saved, and loading it back with guarantees.

'use strict';

const FORMAT_VERSION = 1;

/** Serializes the backlog with metadata, ready to save or send. */
function exportBacklog(tasks, today) {
  const document = {
    version: FORMAT_VERSION,
    exportedOn: today,
    totals: {
      tasks: tasks.length,
      hours: tasks.reduce((acc, t) => acc + t.estimatedHours, 0)
    },
    tasks
  };
  return JSON.stringify(document, null, 2);
}

/** Loads the backlog, validating format, version and content. */
function importBacklog(text) {
  let document;
  try {
    document = JSON.parse(text);
  } catch (error) {
    throw new DataError(`Invalid JSON: ${error.message}`);
  }

  if (document?.version !== FORMAT_VERSION) {
    throw new DataError(`Unsupported format version: ${document?.version}`);
  }
  if (!Array.isArray(document.tasks)) {
    throw new DataError('The document does not contain an array of tasks.');
  }

  const invalid = document.tasks.filter(
    (t) => typeof t.id !== 'number' || typeof t.title !== 'string' || !Array.isArray(t.tags)
  );
  if (invalid.length > 0) {
    throw new DataError(`${invalid.length} task(s) with an incorrect format.`);
  }

  return document.tasks;
}

// ─── The full round trip ──────────────────────────────────────────
const text = exportBacklog(canonicalBacklog, '2026-09-20');
console.log(text.split('\n').slice(0, 8).join('\n'));
{
  "version": 1,
  "exportedOn": "2026-09-20",
  "totals": {
    "tasks": 6,
    "hours": 48
  },
  "tasks": [
const loaded = importBacklog(text);

console.log(loaded.length);                        // 6
console.log(loaded[5].title);                      // 'Carpentry workshop quote'
console.log(loaded.reduce((a, t) => a + t.estimatedHours, 0));   // 48

// And the decisive check: they are NEW objects
console.log(loaded[0] === canonicalBacklog[0]);    // false
loaded[0].tags.push('works');
console.log(canonicalBacklog[0].tags);             // [ 'space', 'design' ]  ✓ untouched

That last block reveals something important: JSON.parse is, in fact, a free deep copy. The loaded backlog is completely independent of the original, which is why the push does not contaminate it.

Three design decisions worth pointing out:

  1. The document carries a version. When the model changes a year from now, the importer will be able to recognize the old format and convert it, rather than failing incomprehensibly.
  2. Validation happens after parsing. Valid JSON does not mean correct data.
  3. An error with a name of its own is thrown, rather than returning null. The caller decides what to do, following the 02-05 criterion.

Saving that text in the browser is literally one line, localStorage.setItem('nomada-tasks', text), and it is the starting point of Local and Session Storage. Sending it to a server is another one, with fetch, in 07-02. Here you already have the essentials done: the conversion and its validation.

Common Mistakes and Tips

1. Confusing the object with its JSON. JSON.stringify(task).title is undefined: a string has no properties.

2. Calling JSON.parse without try/catch. Any corrupt data takes the program down.

3. Parsing twice. JSON.parse(JSON.parse(text)) throws except in rare cases; and if the value is already an object, JSON.parse(object) converts it to a string first and usually fails with Unexpected token o.

4. Expecting JSON.stringify to preserve functions. An object's methods disappear. When you load it back you will have the data, but not the behavior.

5. Believing that { ...obj } copies everything. It copies one level. If there are arrays or objects inside, they are shared.

6. Using JSON.parse(JSON.stringify(x)) with Date, Map, Set or undefined. They are lost or silently transformed. Use structuredClone.

7. Relying on Object.freeze to protect nested structures. It is shallow; you need to freeze deeply.

8. Formatting the JSON you are going to send or save. null, 2 is for reading, not for transmitting: it multiplies the size for no benefit.

Professional tip. Always treat external data as suspect: parse inside a try/catch, validate the shape right afterwards and do not trust any field until you have checked it. The three lines that discipline costs prevent most production failures, and in Module 8 you will write tests precisely for the edge cases you are now anticipating.

Exercises

Exercise 1 — Public export. Write exportPublic(tasks) returning a JSON string formatted with two spaces, containing only the id, title, assignee, status and dueDate fields of each task. Solve it in two ways: with JSON.stringify's array of keys and with a replacer function. Check in both cases that the result does not contain the word reviewer.

Exercise 2 — The backup that actually works. Write createBackup(backlog) and restore(backup), so that the backup is immune to any later modification of the backlog, including the tags and the nested subtasks. Prove with console.log that it works: change a title, push a tag and modify a second-level subtask, then check that the backup is still intact. Explain why [...backlog] would not have done the job.

Exercise 3 — Loss detector. Write whatJsonLoses(object) returning an array with the names of the first-level properties that would not survive JSON.parse(JSON.stringify(object)), stating the reason in the format 'reviewer: undefined disappears'. It must detect undefined, functions, symbols, NaN, Infinity, Date, Map and Set. Try it with an object that has them all.

Solutions

Exercise 1

const PUBLIC_FIELDS = ['id', 'title', 'assignee', 'status', 'dueDate'];

// Way A: array of keys
function exportPublicA(tasks) {
  return JSON.stringify(tasks, PUBLIC_FIELDS, 2);
}

// Way B: replacer function
function exportPublicB(tasks) {
  return JSON.stringify(tasks, (key, value) => {
    if (key === '' || !Number.isNaN(Number(key))) return value;   // the root and the array indexes
    return PUBLIC_FIELDS.includes(key) ? value : undefined;
  }, 2);
}

console.log(exportPublicA(canonicalBacklog).includes('reviewer'));   // false
console.log(exportPublicB(canonicalBacklog).includes('reviewer'));   // false
console.log(exportPublicA(canonicalBacklog).includes('tags'));       // false
console.log(JSON.parse(exportPublicA(canonicalBacklog))[5].title);
// 'Carpentry workshop quote'

The array-of-keys version is far simpler and is the one you should use. The replacer version has a trap worth commenting on: when serializing an array, the replacer is called with the key '' for the root and with '0', '1', '2'… for each element. If you do not let those cases through, the function returns undefined for all of them and the result is an empty JSON. It is the most frequent mistake with replacers, which is why the body's first line explicitly checks for the root and the numeric indexes.

Exercise 2

function createBackup(backlog) {
  return { createdOn: new Date().toISOString().slice(0, 10), tasks: structuredClone(backlog) };
}

function restore(backup) {
  return structuredClone(backup.tasks);
}

const backlog = [
  {
    id: 1,
    title: 'Redesign the multipurpose room',
    tags: ['space', 'design'],
    subtasks: [
      { id: 12, title: 'Choose the furniture', subtasks: [
        { id: 121, title: 'Request quotes', subtasks: [] }
      ] }
    ]
  }
];

const backup = createBackup(backlog);

// Three modifications at different depths
backlog[0].title = 'Redesign the big room';
backlog[0].tags.push('works');
backlog[0].subtasks[0].subtasks[0].title = 'Request three quotes';

console.log(backup.tasks[0].title);
// 'Redesign the multipurpose room'                  ✓ level 1 intact
console.log(backup.tasks[0].tags);
// [ 'space', 'design' ]                             ✓ inner array intact
console.log(backup.tasks[0].subtasks[0].subtasks[0].title);
// 'Request quotes'                                  ✓ level 3 intact

const restored = restore(backup);
console.log(restored[0].title);                      // 'Redesign the multipurpose room'
console.log(restored[0] === backup.tasks[0]);        // false  ← independent too

[...backlog] would not have done the job because it only creates a new array: its elements are still exactly the same objects as the original backlog's, so all three modifications would show up in the backup too. It is the bug from section 14. Notice too that restore clones again: if it returned backup.tasks directly, the application would start modifying the backup itself and it would stop being any use for a second "undo". In this particular case, with pure JSON data, JSON.parse(JSON.stringify(backlog)) would have worked just as well; structuredClone is preferable because it does not depend on the data being serializable.

Exercise 3

function whatJsonLoses(object) {
  const problems = [];

  for (const [key, value] of Object.entries(object)) {
    if (value === undefined) {
      problems.push(`${key}: undefined disappears`);
    } else if (typeof value === 'function') {
      problems.push(`${key}: functions disappear`);
    } else if (typeof value === 'symbol') {
      problems.push(`${key}: symbols disappear`);
    } else if (typeof value === 'number' && !Number.isFinite(value)) {
      problems.push(`${key}: ${value} becomes null`);
    } else if (value instanceof Date) {
      problems.push(`${key}: Date becomes a string`);
    } else if (value instanceof Map || value instanceof Set) {
      problems.push(`${key}: ${value.constructor.name} becomes {}`);
    }
  }

  return problems;
}

const suspect = {
  id: 7,
  title: 'Service the paper guillotine',
  reviewer: undefined,
  compute: () => 42,
  internal: Symbol('x'),
  progress: NaN,
  limit: Infinity,
  createdAt: new Date('2026-09-20'),
  viewedBy: new Set(['Marta']),
  cache: new Map()
};

console.log(whatJsonLoses(suspect));
// [ 'reviewer: undefined disappears',
//   'compute: functions disappear',
//   'internal: symbols disappear',
//   'progress: NaN becomes null',
//   'limit: Infinity becomes null',
//   'createdAt: Date becomes a string',
//   'viewedBy: Set becomes {}',
//   'cache: Map becomes {}' ]

Three technical notes. Number.isFinite(value) catches NaN, Infinity and -Infinity in one go, and is preferable to checking them separately; remember from 01-07 that NaN !== NaN, so comparing it directly would not work. instanceof checks which "mold" an object came from, and it is exactly the mechanism you will study in the next lesson when you look at prototypes. And an honest limitation of this function: it only inspects the first level; a complete version would have to walk the structure recursively, with the technique from 03-07 and the same care about circular references you applied in deepFreeze.

Conclusion

With this lesson you close Module 4. You know that JSON is text, not an object, and you know the table of differences that causes most of the errors: mandatory double quotes on keys and strings, no trailing comma, no comments, no undefined, no functions and no native dates. You have mastered JSON.stringify with its three parameters —the replacer as a function or as an array of keys, and the null, 2 that makes it readable—, you know that an object can decide its own representation with toJSON, and you have memorized what gets lost on the trip: undefined, functions and symbols disappear; Date becomes a string; NaN and Infinity become null; Map and Set are emptied; and a circular reference throws a TypeError. You also know that JSON.parse accepts a reviver for normalizing on the fly and that it must never be called without try/catch, because the text always comes from outside.

And you have solved the wall the previous lesson left behind. A shallow copy{ ...obj }, Object.assign, [...arr], slice()— duplicates a single level and shares everything nested, which turns a "backup" into a dangerous illusion. A deep copy duplicates everything: structuredClone as the default option —it preserves Date, Map, Set and circular references, but not functions— and the old JSON.parse(JSON.stringify(...)) trick when the data is pure JSON, as the canonical backlog is. You have the comparison table, the decision rule about which copy each operation needs, and Object.freeze for locking down the project's constants, with the warning that it too is shallow and that freezing deeply requires recursion. Finally you have written exportBacklog and importBacklog, with a format version, validation after parsing and errors with names of their own: the basis of what will become real persistence in Module 7.

Now look at the module as a whole. You started with six parallel arrays and describeTask demanding eight arguments. You finish with a genuine data model: each task is an object with nine fields, the backlog is an array of objects, and on top of it you have a board with methods and this, the complete catalog of array operations, the six ways of iterating with criteria for choosing between them, the find/filter/sort/reduce arsenal that produces Marta's weekly report, the destructuring that makes signatures and loops readable, the spread that lets you update without destroying, and the serialization that gets the data out of memory. The same numbers as always —48 h, 45 open, Iván with 25, the carpentry quote overdue, effort 124— but now computed in two lines instead of twenty.

There is, however, one repetition you have not managed to avoid. Every time you want a task to know how to describe itself, validate itself or mark itself as done, you have to write those methods inside every object, or keep them as standalone functions you pass the task to. With six tasks it is uncomfortable; with six hundred, each one would drag along its own copy of the same methods, taking up memory to do exactly the same thing. And you have already seen hints that the language has an answer: instanceof, which asks which "mold" an object came from; new Task(...), that third call form we noted down in 04-02; the class ValidationError extends Error you used as a recipe without fully understanding it. They all point in the same direction: what is needed is a shared mold from which every task inherits its behavior, instead of repeating it object by object. That mechanism is the heart of JavaScript and the start of Module 5: Advanced Objects and Functions, which begins with Prototypes and Inheritance and continues with classes, encapsulation, modules and —the course's other great leap— asynchrony.

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