Objetos y arreglos en JavaScript: map, reduce y copias

Curso de JavaScript 3 en jsc: map/filter/reduce, spread, la trampa de la copia superficial, sort() que pone 100 antes que 9 y un reduce O(n²) de 826 ms.

Serie: curso de JavaScript 3/6 (intermedio). Esta clase es para ti si escribes map y filter todos los días pero de vez en cuando te encuentras con el arreglo original modificado, un sort() que pone 100 antes que 9 o un reduce que se vuelve lento sin razón aparente. Pasa en el proyecto final del bootcamp, en el panel que le haces a un cliente como freelancer y en el primer sprint de tu trabajo en una startup. Al terminar vas a elegir el método de arreglo correcto, copiar datos sin compartir por accidente las partes anidadas, ordenar números y textos bien y detectar código cuadrático antes de que llegue a producción.

La mayor parte del JavaScript de un producto real es manejo de datos: una API devuelve un arreglo de objetos y tú lo filtras, lo transformas, lo agrupas, lo ordenas y se lo pasas a la pantalla. Los bugs de esta zona casi nunca son caídas. Son una pantalla de configuración que cambia los valores por defecto de todos los usuarios porque la "copia" era superficial, un ranking donde 100 puntos queda por debajo de 9, o un reporte que tarda un segundo por culpa de un spread de apariencia inocente. Toda la salida que verás viene de la consola jsc de JavaScriptCore en macOS 26.6 (Apple Silicon M1 Pro); el alias está en la clase 1. No se ejecutaron Node.js ni navegadores.

Objetos: propiedades, claves que faltan y ?.

Un objeto es un conjunto de pares clave–valor (propiedades). Las claves son strings (o symbols); los valores pueden ser cualquier cosa.

const user = { name: "Mina", age: 29, "favorite-color": "teal", tags: ["admin"] };
print(user.name, user["favorite-color"], user.email);
user.email = "x";                  // const binding, but the object is mutable
delete user.email;
print(Object.keys(user).join(","), "age" in user, Object.hasOwn(user, "toString"), "toString" in user);
print(user.address?.city, user.address?.city ?? "unknown");
print(JSON.stringify(user));
print(user.address.city);
Mina teal undefined
name,age,favorite-color,tags true false true
undefined unknown
{"name":"Mina","age":29,"favorite-color":"teal","tags":["admin"]}
Exception: TypeError: undefined is not an object (evaluating 'user.address.city')
global code@obj.js:8:19

Lo que importa en la práctica:

  • Una propiedad que no existe vale undefined, no es un error. El error llega un paso después, cuando lees una propiedad de ese undefined. JavaScriptCore nombra la expresión completa que falló (user.address.city); Chrome y Node dicen Cannot read properties of undefined (reading 'city') (no lo ejecutamos aquí).
  • const congela la variable, no el objeto. Agregar y borrar propiedades de un objeto const está permitido.
  • in busca también en la cadena de prototipos (por eso "toString" in user es true); Object.hasOwn solo mira el objeto en sí. Los prototipos son tema de la clase 4.
  • El encadenamiento opcional a?.b se detiene y devuelve undefined si a es null o undefined. Junto con ?? te da un valor por defecto. Úsalo donde la ausencia es normal (un campo opcional), no para esconder datos que siempre deberían estar.

map, filter, reduce y compañía

Los métodos de arreglo reciben un callback, una función que el método llama una vez por elemento. Los tres que más vas a usar:

const orders = [
  { id: 1, item: "pen",    price: 120, qty: 3 },
  { id: 2, item: "note",   price: 300, qty: 1 },
  { id: 3, item: "eraser", price: 80,  qty: 0 },
  { id: 4, item: "pen",    price: 120, qty: 2 },
];
const lines = orders.filter(o => o.qty > 0).map(o => ({ item: o.item, total: o.price * o.qty }));
print(JSON.stringify(lines));
print("sum:", lines.reduce((acc, l) => acc + l.total, 0));
const byItem = orders.reduce((acc, o) => { acc[o.item] = (acc[o.item] ?? 0) + o.qty; return acc; }, {});
print(JSON.stringify(byItem));
print(orders.find(o => o.price > 100).item, orders.findIndex(o => o.qty === 0),
      orders.some(o => o.qty === 0), orders.every(o => o.price > 50));
[{"item":"pen","total":360},{"item":"note","total":300},{"item":"pen","total":240}]
sum: 900
{"pen":5,"note":1,"eraser":0}
pen 2 true true
  • filter se queda con los elementos cuyo callback devuelve un valor truthy; map convierte cada elemento en uno nuevo. Los dos devuelven un arreglo nuevo y no tocan el original.
  • reduce pliega el arreglo en un solo valor. El segundo argumento es el acumulador inicial.
  • find/findIndex se detienen en la primera coincidencia; some/every responden preguntas de sí o no y también cortan antes.
  • Helpers más nuevos que comprobamos en este jsc: Object.groupBy(orders, o => o.item) devolvió {"pen":[…two orders…],…}, [1, [2, [3]]].flat() dio 1,2,3, [1, 2].flatMap(x => [x, x * 10]) dio 1,10,2,20 y [1, 2, 3].at(-1) dio 3.

Olvidar el valor inicial es una caída clásica que solo aparece con datos vacíos, típicamente el día que un usuario nuevo todavía no tiene pedidos:

$ jsc reduce.js        # [].reduce((a, b) => a + b);
Exception: TypeError: reduce of empty array with no initial value
reduce@[native code]
global code@reduce.js:1:10

Con valor inicial, [].reduce((a, b) => a + b, 0) imprimió 0. Pásalo siempre.

Quieres… Usa Devuelve
transformar cada elemento map arreglo nuevo, mismo largo
quedarte con algunos filter arreglo nuevo, igual o más corto
la primera coincidencia / su índice find / findIndex elemento o undefined / índice o -1
¿alguno / todos cumplen? some / every booleano (corta antes)
un solo valor (suma, máximo, objeto) reduce con valor inicial cualquier cosa
agrupar en grupos Object.groupBy (ES2024) objeto de arreglos
solo efectos secundarios for...of nada; admite break

Spread y desestructuración

El spread (...) desempaqueta un arreglo u objeto dentro de uno nuevo; la desestructuración saca valores a variables.

const user = { name: "Mina", age: 29, address: { city: "Springfield" } };
const { name, age: years, role = "member", address: { city } } = user;
print(name, years, role, city);                       // Mina 29 member Springfield
const [first, , third = "none", ...others] = ["a", "b"];
print(first, third, others.length);                   // a none 0
let p = 1, q = 2; [p, q] = [q, p]; print(p, q);       // 2 1
print(JSON.stringify({ ...{ a: 1, b: 2 }, ...{ b: 3 } }), Math.max(...[3, 9, 4]));  // {"a":1,"b":3} 9
function draw({ x = 0, y = 0 } = {}) { return `${x},${y}`; }
print(draw(), draw({ x: 5 }));                        // 0,0 5,0
const { missing: { deep } } = user;
Exception: TypeError: Cannot destructure property 'deep' from null or undefined value
global code@destr.js:10:35

Renombrar (age: years), valores por defecto (role = "member"), saltar posiciones (, ,) e intercambiar variables funcionan como se ve. Los spreads posteriores ganan (b quedó en 3). Desestructurar un parámetro con = {} por defecto permite llamar a la función sin argumento. Desestructurar en profundidad una propiedad que no existe lanza un error, igual que lo haría user.missing.deep.

Error común: la copia superficial

El error. Copiar la configuración con spread y luego editar la copia:

const original = { name: "Mina", prefs: { theme: "dark" }, tags: ["admin"] };
const copy = { ...original };            // shallow copy
copy.name = "Ken";
copy.prefs.theme = "light";
copy.tags.push("editor");
print(original.name, original.prefs.theme, original.tags.join("+"));
print(copy.prefs === original.prefs);
Mina light admin+editor
true

Qué pasa en realidad. El spread copia solo el primer nivel. name es un string, así que la copia recibió el suyo. prefs y tags son objetos, y lo que se copia es la referencia (la flecha que apunta al objeto), de modo que las dos variables apuntan al mismo prefs y al mismo tags. En una interfaz este es el bug de "edité un borrador y cambiaron los valores por defecto para todo el mundo".

Diagrama de decisión para copiar datos. Primera pregunta: ¿contiene objetos o arreglos anidados que vas a cambiar? Si no, basta una copia superficial con spread, { ...obj } o [...arr]. Si sí, segunda pregunta: ¿son datos JSON simples, sin Date, Map, undefined ni BigInt? Si sí, JSON.parse(JSON.stringify(x)) funciona. Si no, usa structuredClone en navegadores y Node 17 o posterior, o un pequeño deepClone recursivo en motores como jsc que no lo tienen.
Elegir una copia. El spread basta solo si no vas a editar nada anidado; JSON sirve solo para datos JSON simples; este jsc no tiene structuredClone.

Las soluciones, y lo que encontramos en jsc:

const withDate = { at: new Date(Date.UTC(2026, 10, 12)), missing: undefined, m: new Map([["a", 1]]) };
const viaJson = JSON.parse(JSON.stringify(withDate));
print(typeof withDate.at, typeof viaJson.at, "missing" in viaJson, JSON.stringify(viaJson.m));
try { JSON.stringify({ n: 1n }); } catch (e) { print(e.name + ": " + e.message); }
print(typeof structuredClone);
try { structuredClone(original); } catch (e) { print(e.name + ": " + e.message); }
object string false {}
TypeError: JSON.stringify cannot serialize BigInt.
undefined
ReferenceError: Can't find variable: structuredClone
  • La ida y vuelta por JSON es una copia profunda solo para datos JSON simples: el Date volvió como string, la propiedad undefined desapareció, el Map se convirtió en {} y un BigInt lanza un error.
  • structuredClone es la respuesta moderna en navegadores y Node 17+ (no ejecutado aquí). Lo define el estándar HTML, no ECMAScript, y este jsc no lo tiene. El código que también deba correr en la consola de un motor o en un runtime embebido antiguo necesita un plan B.

Un plan B pequeño para datos simples, probado aquí:

function deepClone(v) {
  if (v === null || typeof v !== "object") return v;
  if (v instanceof Date) return new Date(v.getTime());
  if (Array.isArray(v)) return v.map(deepClone);
  if (v instanceof Map) return new Map([...v].map(([k, x]) => [k, deepClone(x)]));
  if (v instanceof Set) return new Set([...v].map(deepClone));
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepClone(x)]));
}
const clone = typeof structuredClone === "function" ? structuredClone : deepClone;

Después de clone(original) y de editar campos anidados de la copia, el original imprimió dark admin 1 (sin cambios), y el Date copiado era un Date real e independiente. No maneja ciclos ni instancias de clases; para eso existe structuredClone. Object.freeze tiene la misma superficialidad: tras Object.freeze({ mode: "prod", limits: { max: 10 } }), asignar cfg.limits.max = 99 funcionó (imprimió 99), mientras que cfg.mode = "dev" lanzó TypeError: Attempted to assign to readonly property. en modo estricto.

Las trampas de sort()

const scores = [10, 9, 1, 100, 25];
const sorted = scores.sort();
print(sorted.join(","), "| original now:", scores.join(","), "| same array:", sorted === scores);
print([10, 9, 1, 100, 25].sort((a, b) => a - b).join(","));
const nums = [10, 9, 1, 100, 25];
print(nums.toSorted((a, b) => a - b).join(","), "| nums untouched:", nums.join(","));
print(["b", "a", "C", "ä"].sort().join(","), "|", ["b", "a", "C", "ä"].sort((a, b) => a.localeCompare(b)).join(","));
print([undefined, 3, null, 1].sort().join("|"));
print(["1", "2", "3"].map(parseInt).join(","), ["1", "2", "3"].map(Number).join(","));
1,10,100,25,9 | original now: 1,10,100,25,9 | same array: true
1,9,10,25,100
1,9,10,25,100 | nums untouched: 10,9,1,100,25
C,a,b,ä | a,ä,b,C
1|3||
1,NaN,NaN 1,2,3

Cuatro trampas en seis líneas:

  1. El sort() por defecto compara strings, así que "100" < "25" < "9". Los números necesitan (a, b) => a - b (negativo significa que a va primero).
  2. sort() modifica el arreglo y devuelve el mismo (same array: true). toSorted() (ES2023) devuelve una copia y dejó nums intacto.
  3. Los strings se ordenan por unidad de código UTF-16, así que la C mayúscula va antes que la a y la ä termina al final. Para nombres en español esto rompe palabras con tilde o con ñ; localeCompare da un orden humano.
  4. undefined siempre va al final, y null se compara como el string "null", así que queda después de 3. (join imprime ambos como strings vacíos, de ahí 1|3||.)

La línea map(parseInt) es pariente de la misma familia: map pasa (elemento, índice), y parseInt("2", 1) toma el 1 como base, lo que da NaN.

Un error muy copiado en Stack Overflow es un comparador que devuelve un booleano, (a, b) => a > b. La especificación llama a ese comparador inconsistente, así que el resultado depende de la implementación. Lo probamos con honestidad: en este jsc, 100 arreglos aleatorios con semilla, de 5 a 50,000 números, ordenados con a > b salieron idénticos a los de a - b (0 diferencias). Eso no lo vuelve correcto. Hay muchos reportes públicos de que V8 (Chrome, Node) devuelve resultados sin ordenar con ese comparador desde que pasó a TimSort, alrededor de Chrome 70 (no ejecutado aquí), así que el código que "funciona en Safari" falla en otros lados. Devuelve un número.

Desde ES2019 sort tiene que ser estable: las claves iguales conservan su orden original. Ordenar A30 B25 C30 D25 por edad dio BDAC aquí.

Una medición: spread dentro de reduce es cuadrático

Un estilo "inmutable" muy popular arma un índice con spread dentro de reduce. Construimos {id → item} para N elementos generados con semilla, de las dos formas, y lo mismo con arreglos (spreadreduce.js, mediana de 5 ejecuciones por celda, dos procesos, Apple M1 Pro):

const spreadWay = items => items.reduce((acc, it) => ({ ...acc, [it.id]: it }), {});
const mutateWay = items => items.reduce((acc, it) => { acc[it.id] = it; return acc; }, {});
const arrSpread = items => items.reduce((acc, it) => [...acc, it.score], []);
const arrPush   = items => items.reduce((acc, it) => { acc.push(it.score); return acc; }, []);
N=1000: obj spread 13.9 ms | obj mutate 0.12 ms | arr spread 2.0 ms | arr push 0.04 ms
N=2000: obj spread 57.5 ms | obj mutate 0.08 ms | arr spread 6.6 ms | arr push 0.02 ms
N=4000: obj spread 195.5 ms | obj mutate 0.14 ms | arr spread 22.7 ms | arr push 0.08 ms
N=8000: obj spread 826.1 ms | obj mutate 0.30 ms | arr spread 49.7 ms | arr push 0.04 ms

El segundo proceso dio 13.0 / 52.7 / 196.0 / 859.9 ms en la columna de spread de objetos. Cada vez que N se duplica, la versión con spread tarda unas cuatro veces más: cada paso copia el acumulador entero, así que el trabajo total es 1 + 2 + … + N, que crece con N². Con 8,000 elementos eso es casi un segundo de interfaz congelada. Mutar el acumulador es seguro aquí porque nadie más puede verlo: el objeto "nuevo" lo creó reduce hace un instante. Guarda la inmutabilidad para datos compartidos, no para un acumulador privado.

Gráfico de barras del spread de objetos dentro de reduce. 1,000 elementos tardaron 13.9 milisegundos, 2,000 elementos 57.5 milisegundos, 4,000 elementos 195.5 milisegundos y 8,000 elementos 826.1 milisegundos: unas cuatro veces más cada vez que la entrada se duplica. La versión que muta se mantuvo por debajo de 0.3 milisegundos.
Índice construido con ({ ...acc, [id]: item }) dentro de reduce, mediana de 5 ejecuciones, jsc en macOS 26.6, Apple M1 Pro. Duplicar N cuadruplica más o menos el tiempo; mutar el acumulador tardó 0.3 ms con N = 8,000.

Como referencia, sumar 1,000,000 de números de cuatro formas (misma computadora, cinco ejecuciones, después de calentar) tomó unos 0.5 ms con un bucle for (i), 0.6 ms con reduce, 1.0 ms con for...of y 2.8 ms con forEach. Un factor de cinco, pero menos de 3 ms para un millón de elementos. El método que elijas importa mucho menos que no ser cuadrático.

Límites honestos y notas de versión

  • Motor: JavaScriptCore de macOS 26.6.2 (framework 21624.5.1.11.3) mediante jsc; no se ejecutaron Node.js, Deno ni navegadores. Lo que se dice del comportamiento y los mensajes de V8 está marcado y viene de reportes públicos, no de nuestras pruebas.
  • Ausentes en este jsc: structuredClone (estándar HTML, no ECMAScript) y console. Presentes y comprobados: Object.groupBy, toSorted, toReversed, with, findLast, at, Object.hasOwn, flat/flatMap.
  • Versiones: el spread de arreglos y la desestructuración llegaron con ES2015, el spread de objetos con ES2018, flat/flatMap/Object.fromEntries y el sort estable con ES2019, ?. y ?? con ES2020, at y Object.hasOwn con ES2022, toSorted/toReversed/with/findLast con ES2023 y Object.groupBy con ES2024. Navegadores viejos o motores embebidos pueden no tener los más recientes; compruébalo con typeof como hicimos aquí.
  • Los tiempos son medianas en una sola computadora; el resultado es la forma cuadrática, no los milisegundos exactos.

Ejercicios

1. Ordena usuarios por edad y luego por nombre, sin cambiar el arreglo original: [{ name: "Ken", age: 30 }, { name: "Ann", age: 25 }, { name: "Bo", age: 30 }].

2. const bad = [...matrix]; bad[0][0] = 9; modifica matrix. Explica por qué y corrígelo para un arreglo 2-D sin una copia profunda general.

Respuestas

1. users.toSorted((a, b) => a.age - b.age || a.name.localeCompare(b.name)) imprimió Ann,Bo,Ken, y el orden original siguió siendo Ken,Ann,Bo. Cuando las edades son iguales, a.age - b.age es 0, que es falsy, así que || pasa a la comparación por nombre.

2. El spread copió el arreglo exterior, pero las filas son referencias compartidas; matrix imprimió [[9,2],[3,4]]. Copia un nivel más hondo: const good = m2.map(row => [...row]); dejó m2 como [[1,2],[3,4]] mientras que good pasó a ser [[9,2],[3,4]].

Lo que sigue

La clase 4 explica de dónde salen toString y push: prototipos, la sintaxis class, campos privados #, getters e instanceof, además de qué son los módulos y qué puede y qué no puede ejecutar jsc.

Preguntas frecuentes

¿Por qué [10, 9, 1, 100].sort() pone 100 antes que 9 en JavaScript?

Sin función de comparación, sort() convierte los elementos a texto y los compara carácter por carácter, así que "100" queda antes que "9". Pasa un comparador: arr.sort((a, b) => a - b). Recuerda que sort() además modifica el arreglo original; toSorted() (ES2023) devuelve una copia ordenada.

¿El operador spread hace una copia profunda?

No. { ...obj } y [...arr] copian solo el primer nivel. Los objetos y arreglos anidados se comparten, así que cambiar copy.prefs.theme también cambia original.prefs.theme. Usa structuredClone donde exista (navegadores, Node 17+; no estaba en el jsc que probamos), JSON.parse(JSON.stringify(x)) para datos JSON simples o una pequeña copia recursiva.

¿Es lento usar spread dentro de reduce?

Sí, es cuadrático. Construir un objeto con reduce((acc, x) => ({ ...acc, [x.id]: x }), {}) copia el acumulador entero en cada paso. Con 8,000 elementos tardó unos 830 ms en nuestra prueba con jsc, mientras que mutar el acumulador tardó 0.3 ms. Duplicar la entrada multiplicó el tiempo aproximadamente por cuatro.

  • #javascript
  • #intermedio
  • #arreglos
  • #objetos
  • #reduce
  • #sort
  • #jsc

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑