Clonar objetos en JavaScript: structuredClone vs JSON.parse
Clonar objetos en JavaScript parece trivial hasta que te llaman de urgencia un viernes por la tarde.
Me pasó hace un par de años con una pasarela de reservas. El bug era esquivo: cuando un usuario editaba una reserva antes de pagar, la fecha del check-in cambiaba sola. Otras veces el sistema reventaba con date.toISOString is not a function.
El culpable era una línea en un reducer. Arreglarlo costó cinco minutos. Encontrarlo, dos días.
const updatedState = JSON.parse(JSON.stringify(currentState));
El desarrollador quería una copia profunda (deep clone) para no mutar el estado. Pero JSON.stringify() convirtió todas las fechas Date en cadenas de texto, borró las propiedades con valor undefined, vació los Map y transformó los NaN en null.
La respuesta corta: para clonar un objeto en JavaScript en profundidad, usa structuredClone(obj). Es una función global nativa que copia el objeto y todo su contenido anidado conservando Date, Map, Set, RegExp, BigInt, ArrayBuffer y las referencias circulares. No hay que instalar nada: está disponible en los navegadores como Baseline desde marzo de 2022 y en Node.js desde la 17.0.0, además de Deno y Bun.
Durante una década, el round-trip de JSON fue el parche habitual porque el lenguaje no tenía API nativa de clonación profunda. Hoy la tiene, y mantener el hack es un riesgo que ya no hace falta correr.
Las 7 cosas que JSON.parse(JSON.stringify()) le hace a tus datos
El formato JSON nació para intercambiar datos por red. Nunca se diseñó para serializar estructuras de datos vivas en memoria. Solo conoce seis tipos: string, number, boolean, null, array y objeto plano. Todo lo demás se degrada o desaparece.
Al pasar un objeto por JSON.stringify() y devolverlo con JSON.parse() ocurren siete cosas: cinco corrompen los datos en silencio y dos lanzan un TypeError que al menos te avisa.
| Dato original | Tras JSON.parse(JSON.stringify()) |
Con structuredClone() |
|---|---|---|
new Date("2026-08-20") |
"2026-08-20T00:00:00.000Z" — string |
Date intacto |
{ prop: undefined } |
{} — la propiedad desaparece |
{ prop: undefined } |
{ total: NaN } |
{ total: null } |
NaN |
new Set([1, 2, 3]) |
{} — objeto vacío |
Set intacto |
new Map([["k", "v"]]) |
{} — objeto vacío |
Map intacto |
123n — BigInt |
TypeError: Do not know how to serialize a BigInt |
123n |
| Referencia circular | TypeError: Converting circular structure to JSON |
referencia conservada |
Las dos últimas filas son las buenas: fallan ruidosamente y te enteras en el acto. El peligro real son las cinco primeras, porque la aplicación sigue funcionando mientras tus entidades de negocio pierden sus tipos.
Y el compilador tampoco te va a avisar: JSON.parse() devuelve any, así que TypeScript sigue creyendo que checkIn es un Date mucho después de que haya dejado de serlo. Es justo el hueco que cubre la programación defensiva en TypeScript: el tipo estático no valida nada en tiempo de ejecución.
Qué es structuredClone() y cómo funciona
structuredClone() crea una copia profunda e independiente de un valor: modificar el clon no afecta al original a ninguna profundidad.
Utiliza el algoritmo de clonación estructurada (structured clone algorithm), el mismo estándar que usa el navegador para transferir datos de forma segura entre la ventana principal y los Web Workers o IndexedDB. Por eso entiende de tipos: no serializa a texto, copia estructura.
Es una de esas APIs que ya no tienes que comprobar al mover código entre runtimes, porque la comparten todos — algo que agradeces cuando comparas Bun frente a Node.js en backend TypeScript y quieres que el mismo código corra en los dos.
const original = {
id: 101,
createdAt: new Date(),
tags: new Set(["typescript", "angular", "ia"]),
metadata: new Map([["source", "web"]]),
config: { retries: 3 },
};
// Clonación profunda nativa
const clon = structuredClone(original);
clon.tags.add("nuevo-tag");
clon.config.retries = 10;
// El original permanece intacto, y con sus tipos
console.log(original.tags.has("nuevo-tag")); // false
console.log(original.createdAt instanceof Date); // true
console.log(clon.metadata instanceof Map); // true
Qué NO puede clonar structuredClone()
El algoritmo está diseñado para clonar datos, no comportamiento ni recursos del sistema operativo.
- Funciones y métodos. Clonar
{ fn: () => {} }lanza unaDOMExceptionconname: "DataCloneError". Las funciones capturan closures y no pueden duplicarse de forma determinista. - Nodos del DOM. No puedes clonar un
HTMLElement— para eso existeelement.cloneNode(true). TampocoPromise,WeakMapniWeakSet. - Instancias de clase. Cualquiera, incluso con un solo método. Se clonan sus propiedades de datos, pero el clon llega como objeto plano, sin prototipo.
class Reserva {
constructor(public id: number, public checkIn: Date) {}
estaVencida(): boolean { return this.checkIn < new Date(); }
}
const original = new Reserva(101, new Date("2026-01-01"));
const clon = structuredClone(original);
clon.checkIn instanceof Date; // true — el dato sobrevive
clon instanceof Reserva; // false — el prototipo no
clon.constructor.name; // "Object"
clon.estaVencida(); // TypeError: clon.estaVencida is not a function
// Si necesitas la instancia de vuelta, recoloca el prototipo a mano:
const clonReal = Object.assign(
Object.create(Reserva.prototype),
structuredClone({ ...original }),
);
clonReal instanceof Reserva; // true
Cuando te ves haciendo ese baile a menudo, el problema no es structuredClone(): es que estás mezclando estado y comportamiento en la misma estructura. Separar la entidad de datos del servicio que la opera es uno de los criterios que desarrollo en patrones de diseño avanzados en TypeScript.
Hay dos pérdidas más, menos conocidas y más traicioneras:
- Getters y setters. El clon no hereda el getter, hereda su resultado.
structuredClone()lo ejecuta una vez y guarda el valor como propiedad normal. - Símbolos. Un
Symbolcomo valor lanzaDataCloneError. Como clave, desaparece del clon sin decir nada.
const producto = {
precioBase: 100,
get precioConIva() { return this.precioBase * 1.21; },
[Symbol("interno")]: "no viaja",
};
const clon = structuredClone(producto);
clon.precioConIva; // 121 — valor congelado, ya no es un getter
clon.precioBase = 200;
clon.precioConIva; // 121 — no se recalcula
Object.getOwnPropertySymbols(clon).length; // 0 — la clave Symbol desapareció
Lo mismo aplica a las propiedades no enumerables y a Object.freeze(): no viajan. El clon siempre sale descongelado.
¿structuredClone() es más lento que JSON.parse(JSON.stringify())?
Sí, y conviene decirlo en voz alta porque casi ningún post lo menciona: con objetos planos sin tipos especiales, structuredClone() viene a ser el doble de lento que el round-trip de JSON.
La razón es que JSON.parse lleva más de una década optimizado en C++ dentro de V8, mientras que el algoritmo de clonación estructurada tiene que inspeccionar el tipo de cada valor para decidir cómo copiarlo. Esa inspección es justo lo que estás comprando.
No te doy una cifra por operación a propósito: la medí tres veces sobre el mismo objeto en la misma máquina y el ratio se movió entre 1,5× y 2,2× según el tamaño del payload y la corrida. Mídelo en tu caso si te importa, con tu objeto real. El código para hacerlo cabe en cinco líneas.
Ahora bien, hablamos de fracciones de milisegundo por cada centenar de objetos. Si eso es tu cuello de botella, el problema no es el clonado: es que estás clonando cientos de objetos en el camino crítico. Cambiar corrección por medio milisegundo es un mal negocio, y el único escenario donde JSON gana de verdad es cuando ya sabes que tu payload es JSON puro porque acaba de llegar de un fetch().
structuredClone() vs lodash cloneDeep: cuándo sigues necesitando la librería
structuredClone() sustituye a cloneDeep() en la mayoría de casos y te ahorra la dependencia. Pero no en todos:
- Clases y prototipos.
cloneDeep()conserva el prototipo: el clon denew Pedido()sigue siendo unPedidocon sus métodos.structuredClone()no, porque el algoritmo de clonación estructurada no recorre ni duplica la cadena de prototipos. - Funciones dentro del objeto.
cloneDeep()copia la referencia.structuredClone()lanzaDataCloneErrory aborta el clonado entero. - Getters, setters y descriptores. Tampoco se duplican: una propiedad de solo lectura sale de lectura y escritura en el clon.
- Símbolos.
structuredClone()los rechaza.
En sentido contrario, structuredClone() cubre cosas que cloneDeep no: BigInt, ArrayBuffer, Blob y las referencias circulares sin trucos.
La regla que uso: si tu estado son datos —el caso normal en una arquitectura con Signals o un store inmutable—, structuredClone() y fuera la dependencia. Si tu estado son instancias con comportamiento, no clones: replantea el modelo.
Si tu arquitectura se apoya en inmutabilidad —un store de Signals, un reducer, cualquier cosa que compare por referencia—, structuredClone() encaja de forma natural, porque el estado debe ser datos puros. En el curso de Angular Moderno hay módulos enteros dedicados a montar esa arquitectura con Signals sin mutaciones accidentales.
Clonar no es validar
structuredClone() garantiza que el clon tiene los mismos tipos que el original. No que el original sea correcto.
Si el objeto viene de una API, de localStorage o de un formulario, clonarlo solo te da dos copias del mismo problema. Un Date que en realidad era el string "2026-13-45" seguirá siendo basura después de clonarlo. Valida en el borde, clona dentro del dominio.
Y aquí hay un detalle que casi nadie aprovecha: parse() de Zod no te devuelve el objeto que le pasaste, sino uno nuevo reconstruido campo a campo. La validación ya te está dando una copia.
import { z } from "zod";
const ReservaSchema = z.object({
id: z.number().int().positive(),
checkIn: z.coerce.date(), // string ISO → Date real
tags: z.array(z.string()).default([]),
});
const payload = await fetch("/api/reservas/101").then((r) => r.json());
// Zod valida, convierte tipos y devuelve un objeto NUEVO
const reserva = ReservaSchema.parse(payload);
reserva.checkIn instanceof Date; // true — z.coerce.date() lo reconstruyó
reserva !== payload; // true — ya es una copia
reserva.tags !== payload.tags; // true — también los arrays anidados
// structuredClone() solo hace falta para la SIGUIENTE copia
const borrador = structuredClone(reserva);
borrador.tags.push("editado");
reserva.tags.length; // 1 — el validado no se toca
borrador.tags.length; // 2
Si estás llamando a structuredClone(payload) justo después de schema.parse(payload), estás clonando dos veces. Los patrones de contrato y coerción los desgloso en el curso de Zod para TypeScript, y el montaje completo hasta el store está en gestión de estado global con Zod y Signals.
Tu tarea para hoy en el repositorio
Abre el editor y haz una búsqueda global:
JSON.parse(JSON.stringify(
Si encuentras coincidencias:
- Reemplázalas por
structuredClone(obj). Ganas soporte inmediato para fechas, sets, maps y referencias circulares sin dependencias externas. - Si la llamada empieza a lanzar
DataCloneError, no lo tapes con un try/catch. Acabas de descubrir que había funciones en ese objeto y que el hack de JSON te las estaba borrando en silencio. - Desinstala lo que ya no necesitas. Si arrastrabas
lodashsolo porcloneDeep, tienes una dependencia menos en el bundle. - Blinda el cambio con un test. Una aserción de referencia detecta la regresión el día que alguien vuelva a meter el hack.
it("no muta el estado original al clonar", () => {
const original = { checkIn: new Date("2026-01-01"), tags: new Set(["web"]) };
const clon = structuredClone(original);
clon.tags.add("editado");
expect(original.tags.has("editado")).toBe(false);
expect(clon.checkIn).not.toBe(original.checkIn); // referencia distinta
expect(clon.checkIn).toEqual(original.checkIn); // mismo valor
});
Cómo montar estas suites en proyectos reales lo tienes en el curso de Testing en Angular y TypeScript.
En Dominicode Labs revisamos código real de los miembros y cazamos justo este tipo de patrón obsoleto: el que no rompe nada hasta que rompe todo.
El código moderno no consiste en instalar más paquetes. Consiste en conocer lo que el lenguaje ya trae y usarlo con criterio.
Preguntas frecuentes sobre clonar objetos en JavaScript
¿Cómo clono un objeto en JavaScript sin modificar el original?
Usa structuredClone(objeto). Devuelve una copia profunda e independiente: modificar el clon no afecta al original, ni siquiera en propiedades anidadas a cualquier profundidad. Si solo necesitas copiar el primer nivel y todos los valores son primitivos, el spread { ...objeto } es suficiente y más rápido, pero con objetos anidados el spread copia referencias compartidas y acabarás mutando el original sin darte cuenta.
¿Por qué JSON.parse(JSON.stringify()) rompe mis datos?
Porque JSON es un formato de intercambio por red, no de serialización de memoria, y solo conoce seis tipos: string, number, boolean, null, array y objeto plano. Todo lo demás se degrada o desaparece. Los Date se convierten en cadenas de texto, las propiedades con valor undefined se eliminan, los NaN pasan a null, y los Map y Set quedan como objetos vacíos porque su contenido vive en slots internos que JSON.stringify() no sabe leer. Ninguna de esas cinco pérdidas lanza un error: tu aplicación sigue corriendo con los datos ya corrompidos.
¿structuredClone() funciona en Node.js?
Sí, como función global y sin importar nada, desde Node.js 17.0.0. También está en Deno y en Bun. En navegadores es Baseline desde marzo de 2022, así que ya no necesitas polyfill salvo que tengas que soportar versiones anteriores a esa fecha, donde la alternativa es el paquete @ungap/structured-clone.
¿Por qué structuredClone() lanza DataCloneError?
Porque el objeto contiene algo que el algoritmo no sabe copiar: casi siempre una función, un símbolo o un nodo del DOM escondido en alguna propiedad anidada. El algoritmo clona datos, no comportamiento, y una función captura su ámbito léxico, que no se puede duplicar de forma determinista. El mensaje no te da la ruta hasta la propiedad culpable, así que la vía rápida es ir clonando por capas hasta aislarla.
¿structuredClone() conserva las clases y sus métodos?
No. La cadena de prototipos no se recorre ni se duplica, así que el clon de una instancia de clase conserva sus propiedades de datos pero llega como objeto plano, sin métodos y con constructor.name igual a Object. Lo peligroso es que esto no lanza ningún error: el fallo aparece más tarde, cuando alguien llama a un método que ya no existe. Si necesitas la instancia completa, clona solo los datos y reconstruye con new MiClase(datos), o añade un método clone() propio a la clase.
¿Sigo necesitando lodash cloneDeep?
Solo si clonas instancias de clase y necesitas conservar el prototipo, si el objeto contiene funciones, o si dependes de getters, setters y descriptores de propiedad. Para datos puros, structuredClone() cubre más tipos que cloneDeep —incluidos BigInt y las referencias circulares— sin añadir un solo byte a tu bundle.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
