La lección anterior terminó chocando con un muro: el spread copia solo el primer nivel, así que copia.etiquetas.push('mobiliario') modifica también el original. Y quedó abierta otra pregunta igual de importante: el backlog de Nómada Tareas vive en memoria y desaparece en cuanto se cierra la pestaña; para guardarlo o enviarlo hay que convertirlo en texto. Las dos cuestiones se responden con las mismas herramientas, y ese es el tema de esta lección: JSON como formato de intercambio, con JSON.stringify y JSON.parse y todo lo que se pierde en el viaje de ida y vuelta; y las copias, superficiales y profundas, con structuredClone como respuesta moderna y Object.freeze como red de seguridad. Es la última lección del módulo, así que también cerraremos el círculo: verás qué te falta todavía para que cada tarea deje de repetir los mismos métodos una y otra vez.
Contenido
- Qué es JSON
- JSON no es JavaScript: la tabla de diferencias
JSON.stringify: convertir a texto- Dar formato con el tercer parámetro
- Filtrar con el replacer y con el array de claves
toJSON: que un objeto decida cómo se serializa- Qué se pierde en el viaje de ida y vuelta
JSON.parse: volver a convertir en objeto- El reviver: transformar mientras se lee
JSON.parsesiempre dentro detry/catch- Copia superficial:
{ ...obj }yObject.assign - Copia profunda:
structuredCloney el truco con JSON - La tabla de las copias
- Referencias compartidas: el bug del backlog
Object.freezey su superficialidad- Ejemplo integrado: guardar y recuperar el backlog
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué es JSON
JSON (JavaScript Object Notation) es un formato de texto para representar datos estructurados. Nació de la sintaxis de los objetos de JavaScript, pero hoy es un estándar independiente que entienden prácticamente todos los lenguajes: cuando una aplicación web pide datos a un servidor (Módulo 7), lo que viaja por la red es casi siempre JSON.
Un objeto JavaScript y su representación JSON, lado a lado:
// Objeto JavaScript, un valor vivo en memoria
const tarea = { id: 6, titulo: 'Presupuesto de la carpintería', horasEstimadas: 5 };Ese segundo bloque es un string. No tiene propiedades, no se le puede hacer tarea.titulo, no ocupa memoria como objeto: es una secuencia de caracteres que describe un objeto. La diferencia parece obvia y es la fuente del malentendido número uno con JSON.
JSON es texto. Un objeto se «serializa» a JSON para poder guardarlo, enviarlo o compararlo; y se «parsea» de vuelta para volver a trabajar con él.
Los tipos que JSON admite son solo seis: string, number, boolean, null, objeto y array. Nada más.
- JSON no es JavaScript: la tabla de diferencias
La sintaxis se parece, pero JSON es mucho más estricto. Estas son las diferencias que provocan errores reales:
| Aspecto | Objeto JavaScript | JSON |
|---|---|---|
| Comillas en las claves | Opcionales | Obligatorias, y dobles: {"id": 6} |
| Comillas en los strings | Simples o dobles | Solo dobles |
| Coma final | Permitida | Prohibida: {"a":1,} es error |
| Comentarios | // y /* */ |
No existen |
undefined |
Válido | No existe |
| Funciones | Válidas | No existen |
| Fechas | Objeto Date |
No existen: se guardan como texto |
NaN, Infinity |
Válidos | No existen |
| Claves calculadas | { [c]: v } |
No |
// JSON válido
'{"id": 6, "titulo": "Presupuesto de la carpintería", "revisor": null}'
// JSON inválido (aunque sería JavaScript perfectamente válido)
"{id: 6}" // ✗ clave sin comillas
"{'id': 6}" // ✗ comillas simples
'{"id": 6,}' // ✗ coma final
'{"id": 6, "estado": undefined}' // ✗ undefined no existe en JSONTen presente además que fechaLimite es un string en el modelo de Nómada Tareas. Esa decisión de diseño del Módulo 1, que entonces pudo parecer arbitraria, cobra aquí todo el sentido: como JSON no tiene fechas nativas, guardar '2026-09-05' significa que el dato sobrevive intacto al viaje de ida y vuelta, sin conversiones ni sorpresas.
JSON.stringify: convertir a texto
JSON.stringify: convertir a textoconst tarea = {
id: 6,
titulo: 'Presupuesto de la carpintería',
responsable: 'Iván',
prioridad: 'alta',
estado: 'pendiente',
etiquetas: ['carpintería', 'compras'],
horasEstimadas: 5,
fechaLimite: '2026-09-05',
revisor: 'Marta'
};
const texto = JSON.stringify(tarea);
console.log(typeof texto); // 'string'
console.log(texto);
// {"id":6,"titulo":"Presupuesto de la carpintería","responsable":"Iván","prioridad":"alta","estado":"pendiente","etiquetas":["carpintería","compras"],"horasEstimadas":5,"fechaLimite":"2026-09-05","revisor":"Marta"}Funciona con cualquier valor, no solo con objetos:
console.log(JSON.stringify(42)); // '42'
console.log(JSON.stringify('hola')); // '"hola"' ← ojo: con comillas dentro
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 ← ¡no es un string!Esa última línea sorprende: JSON.stringify(undefined) no devuelve 'undefined', devuelve el valor undefined. Es un detalle que puede reventar un localStorage.setItem en el Módulo 7.
- Dar formato con el tercer parámetro
El tercer parámetro añade saltos de línea e indentación. Es lo que quieres cuando el JSON lo va a leer una persona:
{
"id": 6,
"titulo": "Presupuesto de la carpintería",
"responsable": "Iván",
"prioridad": "alta",
"estado": "pendiente",
"etiquetas": [
"carpintería",
"compras"
],
"horasEstimadas": 5,
"fechaLimite": "2026-09-05",
"revisor": "Marta"
}El null del medio es el segundo parámetro (el replacer, apartado siguiente), que aquí no usamos. El 2 es el número de espacios de indentación; también admite un string, como '\t' para tabuladores.
Truco de depuración muy útil.
console.log(objeto)a veces recorta objetos grandes o muestra[Object]en los niveles profundos.console.log(JSON.stringify(objeto, null, 2))lo muestra entero y legible. Lo usarás constantemente en el Módulo 8.
Para guardar o enviar, en cambio, no formatees: la indentación multiplica el tamaño del texto sin aportar nada a una máquina.
- Filtrar con el replacer y con el array de claves
El segundo parámetro decide qué se serializa. Admite dos formas.
Un array de claves, la más sencilla: solo esas propiedades pasan el filtro.
console.log(JSON.stringify(tarea, ['id', 'titulo', 'estado']));
// {"id":6,"titulo":"Presupuesto de la carpintería","estado":"pendiente"}Una función replacer, que se llama para cada par clave-valor y decide qué devolver. Si devuelve undefined, la propiedad se omite.
const publico = JSON.stringify(tarea, (clave, valor) => {
if (clave === 'revisor') return undefined; // se omite
if (clave === 'titulo') return valor.toUpperCase(); // se transforma
return valor; // el resto, tal cual
}, 2);
console.log(publico.includes('revisor')); // false
console.log(publico.includes('PRESUPUESTO DE LA CARPINTERÍA')); // trueDos detalles del replacer: se llama también para la raíz, con la clave '' y el objeto entero como valor; y recuerda que en un array, las claves son los índices como string. Si te olvidas del return valor final, todo se convierte en undefined y el resultado es vacío.
Aplicación real: exportar el backlog sin los campos internos.
const CAMPOS_PUBLICOS = ['id', 'titulo', 'responsable', 'prioridad', 'estado', 'horasEstimadas', 'fechaLimite'];
const exportable = JSON.stringify([tarea], CAMPOS_PUBLICOS, 2);
console.log(exportable.includes('revisor')); // false
console.log(exportable.includes('etiquetas')); // false
toJSON: que un objeto decida cómo se serializa
toJSON: que un objeto decida cómo se serializaSi un objeto tiene un método llamado toJSON, JSON.stringify lo llama y serializa lo que devuelva en lugar del objeto. Es la forma de controlar la representación desde dentro:
const tablero = {
nombre: 'Taller Nómada',
hoy: '2026-09-20',
tareas: [tarea],
cacheInterna: { calculadoEn: 1758300000000 }, // dato interno que no queremos exportar
toJSON() {
return {
nombre: this.nombre,
exportadoEl: this.hoy,
totalTareas: this.tareas.length,
tareas: this.tareas
};
}
};
console.log(JSON.stringify(tablero, null, 2).includes('cacheInterna')); // false
console.log(JSON.parse(JSON.stringify(tablero)).totalTareas); // 1Lo mismo hace Date internamente: tiene su propio toJSON que devuelve la fecha en formato ISO, y por eso una fecha se serializa como texto.
console.log(JSON.stringify({ creada: new Date('2026-09-20T10:30:00Z') }));
// {"creada":"2026-09-20T10:30:00.000Z"}
- Qué se pierde en el viaje de ida y vuelta
Este es el apartado que hay que memorizar. Serializar y volver a parsear no devuelve siempre lo mismo que entró:
const original = {
id: 7,
titulo: 'Revisar la guillotina',
revisor: undefined, // undefined
calcular: () => 42, // función
creada: new Date('2026-09-20'), // Date
progreso: NaN, // NaN
limite: Infinity, // Infinity
etiquetas: ['encuadernación'],
interno: Symbol('x') // Symbol
};
const vuelta = JSON.parse(JSON.stringify(original));
console.log(vuelta);
// {
// id: 7,
// titulo: 'Revisar la guillotina',
// creada: '2026-09-20T00:00:00.000Z', ← ahora es un STRING
// progreso: null, ← NaN se convirtió en null
// limite: null, ← Infinity también
// etiquetas: [ 'encuadernación' ]
// }
// revisor, calcular e interno han DESAPARECIDOLa tabla completa:
| Valor original | Tras stringify + parse |
|---|---|
string, number finito, boolean, null |
Igual ✓ |
| Objeto, array | Igual (pero objetos nuevos) ✓ |
undefined en una propiedad |
La propiedad desaparece |
undefined en un array |
Se convierte en null |
| Función | La propiedad desaparece |
Symbol |
La propiedad desaparece |
Date |
Se convierte en string ISO |
NaN, Infinity, -Infinity |
Se convierten en null |
Map, Set |
Se convierten en {} (¡se pierde todo!) |
| Referencia circular | Lanza TypeError |
Los dos últimos merecen atención especial:
console.log(JSON.stringify(new Set([1, 2, 3]))); // '{}' ✗ vacío
console.log(JSON.stringify(new Map([['a', 1]]))); // '{}' ✗ vacío
const a = { nombre: 'a' };
a.yoMismo = a; // referencia circular
// JSON.stringify(a);
// ✗ TypeError: Converting circular structure to JSONUna referencia circular es un objeto que se contiene a sí mismo, directa o indirectamente. Ocurre más de lo que parece: por ejemplo, si a cada subtarea le añades un campo padre que apunta a la tarea que la contiene, el árbol deja de ser serializable con JSON.
Buena noticia para Nómada Tareas: el modelo canónico está compuesto solo de números, strings, null, arrays de strings y objetos anidados. Sobrevive intacto al viaje. Eso no es casualidad: es una consecuencia directa de las decisiones de diseño del Módulo 1.
JSON.parse: volver a convertir en objeto
JSON.parse: volver a convertir en objetoconst texto = '{"id":6,"titulo":"Presupuesto de la carpintería","horasEstimadas":5}';
const tarea = JSON.parse(texto);
console.log(typeof tarea); // 'object'
console.log(tarea.titulo); // 'Presupuesto de la carpintería'
console.log(tarea.horasEstimadas + 1); // 6 ← es un número de verdadY con arrays de objetos, que es como llegará el backlog:
const backlog = JSON.parse('[{"id":1,"horasEstimadas":12},{"id":2,"horasEstimadas":6}]');
console.log(Array.isArray(backlog)); // true
console.log(backlog.reduce((acc, t) => acc + t.horasEstimadas, 0)); // 18Un punto crucial: JSON.parse produce objetos completamente nuevos. No hay ninguna relación con los objetos originales, aunque el contenido sea idéntico. Es justo esa propiedad la que aprovecharemos para hacer copias profundas.
- El reviver: transformar mientras se lee
El segundo parámetro de JSON.parse es una función que se llama para cada par clave-valor al leer, y permite transformar los datos en el momento. El uso clásico es reconstruir fechas:
const guardado = '{"titulo":"Revisar la guillotina","creada":"2026-09-20T10:30:00.000Z"}';
const recuperado = JSON.parse(guardado, (clave, valor) => {
if (clave === 'creada' && typeof valor === 'string') return new Date(valor);
return valor;
});
console.log(recuperado.creada instanceof Date); // true
console.log(recuperado.creada.getFullYear()); // 2026En Nómada Tareas el reviver sirve para normalizar datos que llegan de fuera, aplicando las reglas de negocio en la puerta de entrada:
function parsearBacklog(texto) {
return JSON.parse(texto, (clave, valor) => {
if (clave === 'etiquetas' && Array.isArray(valor)) {
return valor.map((e) => e.trim().toLowerCase()); // R9
}
if (clave === 'horasEstimadas' && typeof valor === 'string') {
return Number(valor);
}
return valor;
});
}
const sucio = '[{"id":1,"etiquetas":[" Espacio ","DISEÑO"],"horasEstimadas":"12"}]';
console.log(parsearBacklog(sucio));
// [ { id: 1, etiquetas: [ 'espacio', 'diseño' ], horasEstimadas: 12 } ]Como el replacer, el reviver debe terminar con return valor para todo lo que no transforme; si lo olvidas, el resultado será undefined.
JSON.parse siempre dentro de try/catch
JSON.parse siempre dentro de try/catchJSON.parse lanza una excepción si el texto no es JSON válido. Y el texto casi siempre viene de fuera: de un servidor, de un fichero, del almacenamiento del navegador. Por tanto, es un candidato perfecto para el try/catch de 02-05.
// ✗ Sin protección: un dato corrupto tumba la aplicación
// const datos = JSON.parse(textoDelServidor);
// ✓ Con protección y un valor de reserva
function parsearSeguro(texto, porDefecto = null) {
try {
return JSON.parse(texto);
} catch (error) {
console.warn(`JSON no válido: ${error.message}`);
return porDefecto;
}
}
console.log(parsearSeguro('{"id":6}')); // { id: 6 }
console.log(parsearSeguro('{id: 6}', [])); // [] (con un aviso por consola)
console.log(parsearSeguro('', [])); // []
console.log(parsearSeguro(null, [])); // null ← el valor null se convierte al texto 'null', que sí es JSON válidoLa versión más completa distingue el error de sintaxis de un contenido válido pero inesperado, usando el class ErrorDeValidacion extends Error que conociste en 02-05:
class ErrorDeDatos extends Error {
constructor(mensaje) {
super(mensaje);
this.name = 'ErrorDeDatos';
}
}
function cargarBacklog(texto) {
let datos;
try {
datos = JSON.parse(texto);
} catch (error) {
throw new ErrorDeDatos(`El fichero no contiene JSON válido: ${error.message}`);
}
if (!Array.isArray(datos)) {
throw new ErrorDeDatos('Se esperaba un array de tareas.');
}
if (!datos.every((t) => typeof t.id === 'number' && typeof t.titulo === 'string')) {
throw new ErrorDeDatos('Alguna tarea no tiene id o título válidos.');
}
return datos;
}
try {
cargarBacklog('{"id":1}');
} catch (error) {
console.log(`${error.name}: ${error.message}`);
// ErrorDeDatos: Se esperaba un array de tareas.
}Fíjate en el orden: primero parsear (que puede fallar por sintaxis), después validar la forma de los datos. Parsear correctamente no garantiza que el contenido sea el que esperas.
- Copia superficial:
{ ...obj } y Object.assign
{ ...obj } y Object.assignVolvamos al muro con el que terminó 04-07. Estas son las dos formas de hacer una copia superficial, y las dos tienen el mismo límite:
const tarea = {
id: 1,
titulo: 'Rediseñar la sala polivalente',
etiquetas: ['espacio', 'diseño']
};
const copiaA = { ...tarea };
const copiaB = Object.assign({}, tarea);
console.log(copiaA.etiquetas === tarea.etiquetas); // true ✗ mismo array
console.log(copiaB.etiquetas === tarea.etiquetas); // true ✗ mismo array
copiaA.titulo = 'Cambiado'; // ✓ seguro: string primitivo
copiaA.etiquetas.push('mobiliario'); // ✗ afecta también a tarea y a copiaB
console.log(tarea.titulo); // 'Rediseñar la sala polivalente' ✓
console.log(tarea.etiquetas); // [ 'espacio', 'diseño', 'mobiliario' ] ✗Para arrays, las copias superficiales equivalentes son [...array], array.slice() y Array.from(array). Las tres copian la lista, pero si los elementos son objetos, siguen siendo los mismos objetos:
const lista = [{ id: 1, titulo: 'A' }, { id: 2, titulo: 'B' }];
const copia = [...lista];
copia.push({ id: 3, titulo: 'C' });
console.log(lista.length); // 2 ✓ la lista es independiente
copia[0].titulo = 'MODIFICADO';
console.log(lista[0].titulo); // 'MODIFICADO' ✗ los objetos, no
- Copia profunda:
structuredClone y el truco con JSON
structuredClone y el truco con JSONUna copia profunda duplica el objeto y todo lo que contiene, a cualquier profundidad. Hay dos formas prácticas.
structuredClone(valor) es la respuesta moderna: una función global, integrada en el lenguaje y en los navegadores.
const tarea = {
id: 1,
titulo: 'Rediseñar la sala polivalente',
etiquetas: ['espacio', 'diseño'],
subtareas: [
{ id: 11, titulo: 'Medir y levantar el plano', horasEstimadas: 3, subtareas: [] },
{ id: 12, titulo: 'Elegir el mobiliario', horasEstimadas: 0, subtareas: [
{ id: 121, titulo: 'Pedir presupuestos', horasEstimadas: 2, subtareas: [] }
] }
]
};
const clon = structuredClone(tarea);
clon.etiquetas.push('mobiliario');
clon.subtareas[1].subtareas[0].titulo = 'CAMBIADO';
console.log(tarea.etiquetas); // [ 'espacio', 'diseño' ] ✓
console.log(tarea.subtareas[1].subtareas[0].titulo); // 'Pedir presupuestos' ✓
console.log(clon.subtareas[1].subtareas[0].titulo); // 'CAMBIADO'Sus virtudes: conserva Date, Map, Set, RegExp y datos binarios, y maneja referencias circulares sin lanzar error. Su límite: no puede clonar funciones; si el objeto tiene un método, lanza un DataCloneError.
El truco con JSON, que verás en muchísimo código anterior a structuredClone:
const clonJSON = JSON.parse(JSON.stringify(tarea));
console.log(clonJSON.subtareas[1].subtareas[0].titulo); // 'Pedir presupuestos'Funciona, y es simple de recordar, pero arrastra todas las pérdidas del apartado 7: undefined, funciones y símbolos desaparecen; las fechas se convierten en texto; NaN e Infinity se convierten en null; Map y Set se vacían; y una referencia circular lanza TypeError. Además es más lento, porque construye un string intermedio completo.
Cuándo usar cada uno:
structuredClonepor defecto, siempre que tu entorno lo tenga.JSON.parse(JSON.stringify(...))solo si sabes que tus datos son «JSON puro» —justo el caso del backlog canónico de Nómada Tareas— o si trabajas en un entorno antiguo.- Copia superficial cuando el objeto no tiene niveles anidados, o cuando el anidado es de solo lectura y nadie lo va a mutar.
- La tabla de las copias
| Técnica | Profundidad | Conserva Date/Map/Set |
Funciones | Circulares | Velocidad |
|---|---|---|---|---|---|
{ ...obj } |
Superficial | Sí (por referencia) | Sí (por referencia) | Sí | Muy rápida |
Object.assign({}, obj) |
Superficial | Sí (por referencia) | Sí (por referencia) | Sí | Muy rápida |
[...arr], arr.slice() |
Superficial | — | — | — | Muy rápida |
structuredClone(obj) |
Profunda | Sí | No (lanza error) | Sí | Rápida |
JSON.parse(JSON.stringify(obj)) |
Profunda | No | No (se pierden) | No (lanza error) | Lenta |
| Copia manual nivel a nivel | La que escribas | La que escribas | Sí | Cuidado | La más rápida si es pequeña |
Y la copia manual, la del apartado 15 de 04-07, que sigue siendo perfectamente válida cuando la estructura es conocida y pequeña:
const copiaManual = {
...tarea,
etiquetas: [...tarea.etiquetas],
subtareas: tarea.subtareas.map((s) => ({ ...s, subtareas: [...s.subtareas] }))
};Fíjate en que solo llega hasta el segundo nivel de subtareas: si mañana Iván añade un tercero, esta copia vuelve a compartir referencias. Ese es exactamente el motivo por el que existen las copias profundas automáticas.
- Referencias compartidas: el bug del backlog
Veamos el problema con un caso realista, el que le ocurriría a Lucía en la aplicación.
const backlog = [
{ id: 1, titulo: 'Rediseñar la sala polivalente', etiquetas: ['espacio', 'diseño'], horasEstimadas: 12 },
{ id: 2, titulo: 'Cartelería del taller de serigrafía', etiquetas: ['serigrafía'], horasEstimadas: 6 }
];
// «Voy a guardar una copia de seguridad antes de tocar nada»
const respaldo = [...backlog]; // ✗ copia SUPERFICIAL
// El usuario edita una tarea
backlog[0].titulo = 'Rediseñar la sala grande';
backlog[0].etiquetas.push('obra');
// «Deshacer»: restauro desde el respaldo
console.log(respaldo[0].titulo); // 'Rediseñar la sala grande' ✗ el respaldo TAMBIÉN cambió
console.log(respaldo[0].etiquetas); // [ 'espacio', 'diseño', 'obra' ] ✗El respaldo no servía de nada: [...backlog] creó un array nuevo, pero con los mismos objetos dentro.
flowchart TD
A["backlog<br/>(array)"] --> T1["tarea 1<br/>{ titulo, etiquetas }"]
A --> T2["tarea 2"]
B["respaldo<br/>(array NUEVO)"] --> T1
B --> T2
T1 --> E["etiquetas<br/>['espacio','diseño']"]
style T1 fill:#f9d5d5,stroke:#c66
La solución, con copia profunda:
const backlog2 = [
{ id: 1, titulo: 'Rediseñar la sala polivalente', etiquetas: ['espacio', 'diseño'], horasEstimadas: 12 }
];
const respaldoBueno = structuredClone(backlog2);
backlog2[0].titulo = 'Rediseñar la sala grande';
backlog2[0].etiquetas.push('obra');
console.log(respaldoBueno[0].titulo); // 'Rediseñar la sala polivalente' ✓
console.log(respaldoBueno[0].etiquetas); // [ 'espacio', 'diseño' ] ✓Y una regla de decisión para no equivocarte nunca:
| Lo que vas a hacer | Copia que necesitas |
|---|---|
Cambiar solo campos primitivos (estado, titulo) |
Superficial: { ...tarea, estado: 'hecha' } |
| Cambiar un array o un objeto interno | Superficial + desplegar ese nivel: { ...t, etiquetas: [...t.etiquetas] } |
| Guardar un respaldo, un historial o enviar a otro sitio | Profunda: structuredClone |
| Enviar por la red o guardar como texto | Serializar con JSON.stringify |
Object.freeze y su superficialidad
Object.freeze y su superficialidadSi quieres impedir de verdad que un objeto se modifique, Object.freeze lo congela:
'use strict';
const PESOS = Object.freeze({ alta: 3, media: 2, baja: 1 });
console.log(Object.isFrozen(PESOS)); // true
// PESOS.alta = 99;
// ✗ TypeError: Cannot assign to read only property 'alta' of object (en modo estricto)
// Sin 'use strict', la asignación falla EN SILENCIO: no cambia nada y no avisa.Congelar impide tres cosas: modificar propiedades existentes, añadir nuevas y borrarlas. Es la herramienta correcta para las constantes de configuración del proyecto —PESOS, MARCAS, ESTADOS_VALIDOS—, porque const solo protege la variable, no el contenido (04-01).
Pero Object.freeze es superficial, exactamente igual que el spread:
const plantilla = Object.freeze({
prioridad: 'media',
etiquetas: ['serigrafía']
});
// plantilla.prioridad = 'alta'; // ✗ TypeError: bien protegido
plantilla.etiquetas.push('urgente'); // ✓ ¡permitido!
console.log(plantilla.etiquetas); // [ 'serigrafía', 'urgente' ] ✗Para congelar en profundidad hay que recorrer la estructura, y aquí vuelve la recursividad de 03-07:
function congelarProfundo(objeto) {
for (const valor of Object.values(objeto)) {
if (valor !== null && typeof valor === 'object' && !Object.isFrozen(valor)) {
congelarProfundo(valor);
}
}
return Object.freeze(objeto);
}
const plantillaSegura = congelarProfundo({ prioridad: 'media', etiquetas: ['serigrafía'] });
try {
plantillaSegura.etiquetas.push('urgente');
} catch (error) {
console.log(error.name); // 'TypeError'
}
console.log(plantillaSegura.etiquetas); // [ 'serigrafía' ] ✓La comprobación !Object.isFrozen(valor) no es un adorno: evita el bucle infinito si hay referencias circulares. Un consejo de uso: congela las constantes y los datos de configuración, no el estado que la aplicación modifica constantemente; para eso, la disciplina de actualizaciones inmutables de 04-07 es mejor herramienta que la prohibición.
- Ejemplo integrado: guardar y recuperar el backlog
Juntemos todo en la pieza que Nómada Tareas necesitará en el Módulo 7: convertir el backlog en texto para guardarlo, y recuperarlo con garantías.
'use strict';
const VERSION_FORMATO = 1;
/** Serializa el backlog con metadatos, listo para guardar o enviar. */
function exportarBacklog(tareas, hoy) {
const documento = {
version: VERSION_FORMATO,
exportadoEl: hoy,
totales: {
tareas: tareas.length,
horas: tareas.reduce((acc, t) => acc + t.horasEstimadas, 0)
},
tareas
};
return JSON.stringify(documento, null, 2);
}
/** Recupera el backlog validando formato, versión y contenido. */
function importarBacklog(texto) {
let documento;
try {
documento = JSON.parse(texto);
} catch (error) {
throw new ErrorDeDatos(`JSON no válido: ${error.message}`);
}
if (documento?.version !== VERSION_FORMATO) {
throw new ErrorDeDatos(`Versión de formato no soportada: ${documento?.version}`);
}
if (!Array.isArray(documento.tareas)) {
throw new ErrorDeDatos('El documento no contiene un array de tareas.');
}
const invalidas = documento.tareas.filter(
(t) => typeof t.id !== 'number' || typeof t.titulo !== 'string' || !Array.isArray(t.etiquetas)
);
if (invalidas.length > 0) {
throw new ErrorDeDatos(`${invalidas.length} tarea(s) con formato incorrecto.`);
}
return documento.tareas;
}
// ─── Ida y vuelta completa ────────────────────────────────────────
const texto = exportarBacklog(backlogCanonico, '2026-09-20');
console.log(texto.split('\n').slice(0, 8).join('\n'));const recuperado = importarBacklog(texto);
console.log(recuperado.length); // 6
console.log(recuperado[5].titulo); // 'Presupuesto de la carpintería'
console.log(recuperado.reduce((a, t) => a + t.horasEstimadas, 0)); // 48
// Y la comprobación decisiva: son objetos NUEVOS
console.log(recuperado[0] === backlogCanonico[0]); // false
recuperado[0].etiquetas.push('obra');
console.log(backlogCanonico[0].etiquetas); // [ 'espacio', 'diseño' ] ✓ intactoEse último bloque revela algo importante: JSON.parse es, de hecho, una copia profunda gratis. El backlog recuperado es completamente independiente del original, y por eso el push no lo contamina.
Tres decisiones de diseño que merece la pena señalar:
- El documento lleva
version. Cuando dentro de un año el modelo cambie, el importador podrá reconocer el formato antiguo y convertirlo, en vez de fallar de forma incomprensible. - Se valida después de parsear. JSON válido no significa datos correctos.
- Se lanza un error con nombre propio, no se devuelve
null. Quien llame decide qué hacer, siguiendo el criterio de 02-05.
Guardar ese texto en el navegador es literalmente una línea, localStorage.setItem('nomada-tareas', texto), y es el punto de partida de Almacenamiento Local y de Sesión. Enviarlo a un servidor es otra, con fetch, en 07-02. Aquí ya tienes hecho lo esencial: la conversión y su validación.
Errores Comunes y Consejos
1. Confundir el objeto con su JSON. JSON.stringify(tarea).titulo es undefined: un string no tiene propiedades.
2. Llamar a JSON.parse sin try/catch. Cualquier dato corrupto tumba el programa.
3. Parsear dos veces. JSON.parse(JSON.parse(texto)) lanza error salvo casos raros; y si el dato ya es un objeto, JSON.parse(objeto) lo convierte a string primero y suele fallar con Unexpected token o.
4. Esperar que JSON.stringify conserve las funciones. Los métodos de un objeto desaparecen. Al recuperarlo tendrás los datos, pero no el comportamiento.
5. Creer que { ...obj } copia todo. Copia un nivel. Si hay arrays u objetos dentro, se comparten.
6. Usar JSON.parse(JSON.stringify(x)) con Date, Map, Set o undefined. Se pierden o se transforman en silencio. Usa structuredClone.
7. Confiar en Object.freeze para proteger estructuras anidadas. Es superficial; hace falta congelar en profundidad.
8. Formatear el JSON que vas a enviar o guardar. null, 2 es para leer, no para transmitir: multiplica el tamaño sin ninguna ventaja.
Consejo profesional. Trata siempre los datos externos como sospechosos: parsea dentro de un try/catch, valida la forma justo después y no confíes en ningún campo hasta haberlo comprobado. Las tres líneas que cuesta esa disciplina evitan la mayoría de los fallos en producción, y en el Módulo 8 escribirás pruebas precisamente para los casos límite que ahora estás previendo.
Ejercicios
Ejercicio 1 — Exportación pública. Escribe exportarPublico(tareas) que devuelva un string JSON formateado con dos espacios, conteniendo solo los campos id, titulo, responsable, estado y fechaLimite de cada tarea. Resuélvelo de dos formas: con el array de claves de JSON.stringify y con una función replacer. Comprueba en ambos casos que el resultado no contiene la palabra revisor.
Ejercicio 2 — El respaldo que sí funciona. Escribe crearRespaldo(backlog) y restaurar(respaldo), de modo que el respaldo sea inmune a cualquier modificación posterior del backlog, incluidas las etiquetas y las subtareas anidadas. Demuestra con console.log que funciona: modifica un título, empuja una etiqueta y cambia una subtarea de segundo nivel, y comprueba que el respaldo sigue intacto. Explica por qué [...backlog] no habría servido.
Ejercicio 3 — Detector de pérdidas. Escribe quePierdeConJSON(objeto) que devuelva un array con los nombres de las propiedades de primer nivel que no sobrevivirían a JSON.parse(JSON.stringify(objeto)), indicando el motivo en el formato 'revisor: undefined desaparece'. Debe detectar undefined, funciones, símbolos, NaN, Infinity, Date, Map y Set. Pruébalo con un objeto que los tenga todos.
Soluciones
Ejercicio 1
const CAMPOS_PUBLICOS = ['id', 'titulo', 'responsable', 'estado', 'fechaLimite'];
// Forma A: array de claves
function exportarPublicoA(tareas) {
return JSON.stringify(tareas, CAMPOS_PUBLICOS, 2);
}
// Forma B: función replacer
function exportarPublicoB(tareas) {
return JSON.stringify(tareas, (clave, valor) => {
if (clave === '' || !Number.isNaN(Number(clave))) return valor; // raíz e índices del array
return CAMPOS_PUBLICOS.includes(clave) ? valor : undefined;
}, 2);
}
console.log(exportarPublicoA(backlogCanonico).includes('revisor')); // false
console.log(exportarPublicoB(backlogCanonico).includes('revisor')); // false
console.log(exportarPublicoA(backlogCanonico).includes('etiquetas')); // false
console.log(JSON.parse(exportarPublicoA(backlogCanonico))[5].titulo);
// 'Presupuesto de la carpintería'La versión con array de claves es mucho más simple y es la que deberías usar. La versión con replacer tiene una trampa que merece comentario: al serializar un array, el replacer se llama con la clave '' para la raíz y con '0', '1', '2'… para cada elemento. Si no dejas pasar esos casos, la función devuelve undefined para todos y el resultado es un JSON vacío. Es el error más frecuente con replacers, y por eso la primera línea del cuerpo comprueba explícitamente la raíz y los índices numéricos.
Ejercicio 2
function crearRespaldo(backlog) {
return { creadoEl: new Date().toISOString().slice(0, 10), tareas: structuredClone(backlog) };
}
function restaurar(respaldo) {
return structuredClone(respaldo.tareas);
}
const backlog = [
{
id: 1,
titulo: 'Rediseñar la sala polivalente',
etiquetas: ['espacio', 'diseño'],
subtareas: [
{ id: 12, titulo: 'Elegir el mobiliario', subtareas: [
{ id: 121, titulo: 'Pedir presupuestos', subtareas: [] }
] }
]
}
];
const respaldo = crearRespaldo(backlog);
// Tres modificaciones a distintas profundidades
backlog[0].titulo = 'Rediseñar la sala grande';
backlog[0].etiquetas.push('obra');
backlog[0].subtareas[0].subtareas[0].titulo = 'Pedir tres presupuestos';
console.log(respaldo.tareas[0].titulo);
// 'Rediseñar la sala polivalente' ✓ nivel 1 intacto
console.log(respaldo.tareas[0].etiquetas);
// [ 'espacio', 'diseño' ] ✓ array interno intacto
console.log(respaldo.tareas[0].subtareas[0].subtareas[0].titulo);
// 'Pedir presupuestos' ✓ nivel 3 intacto
const restaurado = restaurar(respaldo);
console.log(restaurado[0].titulo); // 'Rediseñar la sala polivalente'
console.log(restaurado[0] === respaldo.tareas[0]); // false ← también independiente[...backlog] no habría servido porque solo crea un array nuevo: sus elementos siguen siendo exactamente los mismos objetos que los del backlog original, así que las tres modificaciones se verían también en el respaldo. Es el bug del apartado 14. Fíjate además en que restaurar vuelve a clonar: si devolviera respaldo.tareas directamente, la aplicación empezaría a modificar el propio respaldo y este dejaría de servir para un segundo «deshacer». En este caso concreto, con datos JSON puros, JSON.parse(JSON.stringify(backlog)) habría funcionado igual de bien; structuredClone es preferible porque no depende de que los datos sean serializables.
Ejercicio 3
function quePierdeConJSON(objeto) {
const problemas = [];
for (const [clave, valor] of Object.entries(objeto)) {
if (valor === undefined) {
problemas.push(`${clave}: undefined desaparece`);
} else if (typeof valor === 'function') {
problemas.push(`${clave}: las funciones desaparecen`);
} else if (typeof valor === 'symbol') {
problemas.push(`${clave}: los símbolos desaparecen`);
} else if (typeof valor === 'number' && !Number.isFinite(valor)) {
problemas.push(`${clave}: ${valor} se convierte en null`);
} else if (valor instanceof Date) {
problemas.push(`${clave}: Date se convierte en string`);
} else if (valor instanceof Map || valor instanceof Set) {
problemas.push(`${clave}: ${valor.constructor.name} se convierte en {}`);
}
}
return problemas;
}
const sospechoso = {
id: 7,
titulo: 'Revisar la guillotina',
revisor: undefined,
calcular: () => 42,
interno: Symbol('x'),
progreso: NaN,
limite: Infinity,
creada: new Date('2026-09-20'),
vistas: new Set(['Marta']),
cache: new Map()
};
console.log(quePierdeConJSON(sospechoso));
// [ 'revisor: undefined desaparece',
// 'calcular: las funciones desaparecen',
// 'interno: los símbolos desaparecen',
// 'progreso: NaN se convierte en null',
// 'limite: Infinity se convierte en null',
// 'creada: Date se convierte en string',
// 'vistas: Set se convierte en {}',
// 'cache: Map se convierte en {}' ]Tres apuntes técnicos. Number.isFinite(valor) captura de una vez NaN, Infinity y -Infinity, y es preferible a comprobarlos por separado; recuerda de 01-07 que NaN !== NaN, así que compararlo directamente no funcionaría. instanceof comprueba de qué «molde» procede un objeto, y es exactamente el mecanismo que estudiarás en la lección siguiente al ver los prototipos. Y una limitación honesta de esta función: solo revisa el primer nivel; una versión completa tendría que recorrer la estructura recursivamente, con la técnica de 03-07 y la misma precaución con las referencias circulares que aplicaste en congelarProfundo.
Conclusión
Con esta lección cierras el Módulo 4. Sabes que JSON es texto, no un objeto, y conoces la tabla de diferencias que provoca la mayoría de los errores: comillas dobles obligatorias en claves y strings, sin coma final, sin comentarios, sin undefined, sin funciones y sin fechas nativas. Dominas JSON.stringify con sus tres parámetros —el replacer como función o como array de claves, y el null, 2 que lo hace legible—, sabes que un objeto puede decidir su propia representación con toJSON, y tienes memorizado qué se pierde en el viaje: undefined, funciones y símbolos desaparecen; Date se convierte en string; NaN e Infinity en null; Map y Set se vacían; y una referencia circular lanza TypeError. También sabes que JSON.parse admite un reviver para normalizar al vuelo y que nunca debe llamarse sin try/catch, porque el texto siempre viene de fuera.
Y has resuelto el muro que dejó la lección anterior. Una copia superficial —{ ...obj }, Object.assign, [...arr], slice()— duplica un solo nivel y comparte todo lo anidado, lo que convierte un «respaldo» en una ilusión peligrosa. Una copia profunda lo duplica todo: structuredClone como opción por defecto —conserva Date, Map, Set y las referencias circulares, pero no las funciones— y el viejo truco JSON.parse(JSON.stringify(...)) cuando los datos son JSON puro, como es el caso del backlog canónico. Tienes la tabla comparativa, la regla de decisión sobre qué copia necesita cada operación, y Object.freeze para blindar las constantes del proyecto, con la advertencia de que también es superficial y que congelar en profundidad exige recursividad. Por último has escrito exportarBacklog e importarBacklog, con versión de formato, validación tras el parseo y errores con nombre propio: la base de lo que en el Módulo 7 se convertirá en persistencia real.
Mira ahora el módulo entero. Empezaste con seis arrays paralelos y describirTarea pidiendo ocho argumentos. Terminas con un modelo de datos de verdad: cada tarea es un objeto con nueve campos, el backlog es un array de objetos, y sobre él tienes un tablero con métodos y this, el catálogo completo de operaciones de array, las seis formas de iterar con criterio para elegir, el arsenal de find/filter/sort/reduce que produce el informe semanal de Marta, la desestructuración que hace legibles firmas y bucles, el spread que permite actualizar sin destruir, y la serialización que saca los datos de la memoria. Los mismos números de siempre —48 h, 45 abiertas, Iván con 25, la carpintería vencida, esfuerzo 124— pero ahora calculados con dos líneas en lugar de veinte.
Queda, sin embargo, una repetición que no has sabido evitar. Cada vez que quieres que una tarea sepa describirse, validarse o marcarse como hecha, tienes que escribir esos métodos dentro de cada objeto, o mantenerlos como funciones sueltas a las que pasas la tarea. Con seis tareas es incómodo; con seiscientas, cada una arrastraría su propia copia de los mismos métodos, ocupando memoria para hacer exactamente lo mismo. Y ya has visto pistas de que el lenguaje tiene una respuesta: instanceof, que pregunta de qué «molde» procede un objeto; new Tarea(...), aquella tercera forma de invocación que dejamos anotada en 04-02; el class ErrorDeValidacion extends Error que usaste como receta sin entenderla del todo. Todas apuntan a lo mismo: hace falta un molde compartido del que todas las tareas hereden su comportamiento, en lugar de repetirlo objeto a objeto. Ese mecanismo es el corazón de JavaScript y el arranque del Módulo 5: Objetos y Funciones Avanzadas, que empieza con Prototipos y Herencia y continúa con las clases, la encapsulación, los módulos y —el otro gran salto del curso— la asincronía.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
