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
- What JSON is
- JSON is not JavaScript: the table of differences
JSON.stringify: converting to text- Formatting with the third parameter
- Filtering with the replacer and with the array of keys
toJSON: letting an object decide how it serializes- What gets lost on the round trip
JSON.parse: turning it back into an object- The reviver: transforming while reading
JSON.parsealways insidetry/catch- Shallow copy:
{ ...obj }andObject.assign - Deep copy:
structuredCloneand the JSON trick - The copying table
- Shared references: the backlog bug
Object.freezeand its shallowness- Worked example: saving and loading the backlog
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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 };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.
- 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 JSONBear 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.
JSON.stringify: converting to text
JSON.stringify: converting to textconst 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.
- 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:
{
"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.
- 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')); // trueTwo 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
toJSON: letting an object decide how it serializes
toJSON: letting an object decide how it serializesIf 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); // 1Date 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"}
- 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 DISAPPEAREDThe 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 JSONA 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.
JSON.parse: turning it back into an object
JSON.parse: turning it back into an objectconst 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 numberAnd 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)); // 18A 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.
- 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()); // 2026In 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.
JSON.parse always inside try/catch
JSON.parse always inside try/catchJSON.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 JSONThe 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.
- Shallow copy:
{ ...obj } and Object.assign
{ ...obj } and Object.assignLet 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
- Deep copy:
structuredClone and the JSON trick
structuredClone and the JSON trickA 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:
structuredCloneby 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.
- 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.
- 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 |
Object.freeze and its shallowness
Object.freeze and its shallownessIf 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 constants —WEIGHTS, 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.
- 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'));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' ] ✓ untouchedThat 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:
- 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. - Validation happens after parsing. Valid JSON does not mean correct data.
- 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
- 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
