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 eseundefined. JavaScriptCore nombra la expresión completa que falló (user.address.city); Chrome y Node dicenCannot read properties of undefined (reading 'city')(no lo ejecutamos aquí). constcongela la variable, no el objeto. Agregar y borrar propiedades de un objetoconstestá permitido.inbusca también en la cadena de prototipos (por eso"toString" in useres true);Object.hasOwnsolo mira el objeto en sí. Los prototipos son tema de la clase 4.- El encadenamiento opcional
a?.bse detiene y devuelveundefinedsiaesnulloundefined. 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
filterse queda con los elementos cuyo callback devuelve un valor truthy;mapconvierte cada elemento en uno nuevo. Los dos devuelven un arreglo nuevo y no tocan el original.reducepliega el arreglo en un solo valor. El segundo argumento es el acumulador inicial.find/findIndexse detienen en la primera coincidencia;some/everyresponden 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()dio1,2,3,[1, 2].flatMap(x => [x, x * 10])dio1,10,2,20y[1, 2, 3].at(-1)dio3.
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".
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
Datevolvió como string, la propiedadundefineddesapareció, elMapse convirtió en{}y unBigIntlanza un error. structuredClonees la respuesta moderna en navegadores y Node 17+ (no ejecutado aquí). Lo define el estándar HTML, no ECMAScript, y estejscno 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:
- 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). sort()modifica el arreglo y devuelve el mismo (same array: true).toSorted()(ES2023) devuelve una copia y dejónumsintacto.- Los strings se ordenan por unidad de código UTF-16, así que la
Cmayúscula va antes que laay laätermina al final. Para nombres en español esto rompe palabras con tilde o con ñ;localeCompareda un orden humano. undefinedsiempre va al final, ynullse compara como el string"null", así que queda después de3. (joinimprime 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.
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) yconsole. 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.fromEntriesy el sort estable con ES2019,?.y??con ES2020,atyObject.hasOwncon ES2022,toSorted/toReversed/with/findLastcon ES2023 yObject.groupBycon ES2024. Navegadores viejos o motores embebidos pueden no tener los más recientes; compruébalo contypeofcomo 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.
- Anterior: curso de JavaScript 2/6: funciones, scope y closures
- Siguiente: curso de JavaScript 4/6: clases, prototipos y módulos
- Más adelante en la serie: async, promesas y el event loop, rendimiento, memoria y números
- Para comparar: streams, lambdas y Optional en Java (en inglés), tipos y mutabilidad en Python
- Dónde encaja esto: la hoja de ruta para aprender a programar
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.