JavaScript Objects and Arrays: map, reduce, Copy Traps
JavaScript lecture 3, run in macOS jsc: map/filter/reduce, spread and destructuring, the shallow copy trap, sort() putting 100 before 9, an O(n²) reduce.
Series: JavaScript lecture 3/6 (intermediate). For developers who write map and filter every day but sometimes get a mutated original, a sort() that puts 100 before 9, or a reduce that becomes mysteriously slow. After this lecture you will choose the right array method, copy data without sharing nested parts by accident, sort numbers and strings correctly, and spot quadratic code before it reaches production.
Most JavaScript in a real product is data handling: an API returns an array of objects, and you filter it, reshape it, group it, sort it and hand it to the screen. The bugs here are rarely crashes. They are a settings screen that changes the defaults for every user because a "copy" was shallow, a ranking where 100 points sorts below 9, or a report that takes a second because of one innocent-looking spread. Every output below comes from JavaScriptCore's jsc shell on macOS 26.6 (Apple Silicon M1 Pro); see lecture 1 for the alias. Node.js and browsers were not run.
Objects: properties, missing keys and ?.
An object is a set of key–value pairs (properties). Keys are strings (or symbols); values can be anything.
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
The points that matter in practice:
- A missing property is
undefined, not an error. The error comes one step later, when you read a property of thatundefined. JavaScriptCore names the whole failing expression (user.address.city); Chrome and Node sayCannot read properties of undefined (reading 'city')(not run here). constfreezes the binding, not the object. Adding and deleting properties on aconstobject is fine.inlooks up the prototype chain (so"toString" in useris true),Object.hasOwnonly checks the object itself. Prototypes are lecture 4.- Optional chaining
a?.bstops and returnsundefinedifaisnullorundefined. Combined with??it gives a default. Use it where absence is normal (an optional field), not to hide data that should always be there.
map, filter, reduce and friends
The array methods take a callback, a function the method calls once per element. The three you will use most:
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
filterkeeps the elements whose callback returns a truthy value;mapturns each element into a new one. Both return a new array and leave the original alone.reducefolds the array into one value. The second argument is the starting accumulator.find/findIndexstop at the first match;some/everyanswer yes/no questions and also stop early.- Newer helpers we checked in this
jsc:Object.groupBy(orders, o => o.item)returned{"pen":[…two orders…],…},[1, [2, [3]]].flat()gave1,2,3,[1, 2].flatMap(x => [x, x * 10])gave1,10,2,20, and[1, 2, 3].at(-1)gave3.
Forgetting the starting value is a classic crash that only shows up on empty data:
$ 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
With an initial value, [].reduce((a, b) => a + b, 0) printed 0. Always pass one.
| You want to… | Use | Returns |
|---|---|---|
| transform every element | map |
new array, same length |
| keep some elements | filter |
new array, shorter or equal |
| the first match / its index | find / findIndex |
element or undefined / index or -1 |
| any / all match? | some / every |
boolean (stops early) |
| one value (sum, max, object) | reduce with an initial value |
anything |
| group into buckets | Object.groupBy (ES2024) |
object of arrays |
| just side effects | for...of |
nothing; can break |
Spread and destructuring
Spread (...) unpacks an array or object into a new one; destructuring pulls values out into 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
Renaming (age: years), defaults (role = "member"), skipping (, ,) and swapping all work as shown. Later spreads win (b became 3). Destructuring a parameter with = {} as default lets callers omit the argument entirely. Nested destructuring of a property that is missing throws, just like user.missing.deep would.
Common mistake: the shallow copy
The mistake. Copying settings with spread, then editing the copy:
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
What actually happens. Spread copies the top level only. name is a string, so the copy got its own. prefs and tags are objects, and what gets copied is the reference (the arrow pointing to the object), so both variables point to the same prefs and the same tags. In a UI this is the "I edited a draft and the defaults changed for everybody" bug.
The fixes, and what we found in 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
- The JSON round trip is a deep copy only for plain JSON data: the
Datecame back as a string, theundefinedproperty disappeared, theMapbecame{}, and aBigIntthrows. structuredCloneis the modern answer in browsers and Node 17+ (not run here). It is defined by the HTML standard, not by ECMAScript, and thisjscdoes not have it. Code that must also run in an engine shell, or in an older embedded runtime, needs a fallback.
A small fallback for plain data, tested here:
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;
After clone(original) and editing nested fields of the copy, the original printed dark admin 1 (unchanged), and the copied Date was a real, separate Date. It does not handle cycles or class instances; that is what structuredClone is for. Object.freeze has the same shallowness: after Object.freeze({ mode: "prod", limits: { max: 10 } }), assigning cfg.limits.max = 99 worked (printed 99), while cfg.mode = "dev" threw TypeError: Attempted to assign to readonly property. in strict mode.
The sort() traps
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
Four traps in six lines:
- Default
sort()compares strings, so"100" < "25" < "9". Numbers need(a, b) => a - b(negative means a first). sort()mutates and returns the same array (same array: true).toSorted()(ES2023) returns a copy and leftnumsuntouched.- Strings sort by UTF-16 code unit, so capital
Ccomes beforea, andälands at the end.localeComparegives a human order. undefinedalways goes last, andnullis compared as the string"null", so it lands after3. (joinprints both as empty strings, hence1|3||.)
The map(parseInt) line is a neighbour of the same family: map passes (element, index), and parseInt("2", 1) treats 1 as the radix, which gives NaN.
A widely copied mistake is a comparator that returns a boolean, (a, b) => a > b. The spec calls such a comparator inconsistent, so the result is implementation-defined. We tested it honestly: in this jsc, 100 seeded random arrays of 5 to 50,000 numbers sorted with a > b came out identical to a - b (0 mismatches). That does not make it correct. V8 (Chrome, Node) has been widely reported to return unsorted results for it since it switched to TimSort around Chrome 70 (not run here), so code that "works on Safari" fails elsewhere. Return a number.
Since ES2019 sort must be stable: equal keys keep their original order. Sorting A30 B25 C30 D25 by age gave BDAC here.
A measurement: spread inside reduce is quadratic
A popular "immutable" style builds an index with spread inside reduce. We built {id → item} for N seeded items, both ways, plus the same for arrays (spreadreduce.js, median of 5 runs per cell, two processes, 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
The second process gave 13.0 / 52.7 / 196.0 / 859.9 ms for the object spread column. Each time N doubles, the spread version takes about four times longer: every step copies the whole accumulator, so the total work is 1 + 2 + … + N, which grows with N². At 8,000 items that is close to a second of frozen UI. Mutating the accumulator is safe here because nobody else can see it: the "fresh" object was created by reduce a moment ago. Keep immutability for data that is shared, not for a private accumulator.
For context, summing 1,000,000 numbers four ways (same machine, five runs, after warm-up) took about 0.5 ms with a for (i) loop, 0.6 ms with reduce, 1.0 ms with for...of and 2.8 ms with forEach. A factor of five, but under 3 ms for a million elements. The method you pick matters far less than not being quadratic.
Honest limits / version notes
- Engine: JavaScriptCore from macOS 26.6.2 (framework 21624.5.1.11.3) via
jsc; Node.js, Deno and browsers not run. Statements about V8 behaviour and messages are labelled and come from public reports, not from our runs. - Missing in this jsc:
structuredClone(HTML standard, not ECMAScript) andconsole. Present and checked:Object.groupBy,toSorted,toReversed,with,findLast,at,Object.hasOwn,flat/flatMap. - Versions: array spread and destructuring came with ES2015, object spread with ES2018,
flat/flatMap/Object.fromEntriesand stable sort with ES2019,?.and??with ES2020,atandObject.hasOwnwith ES2022,toSorted/toReversed/with/findLastwith ES2023,Object.groupBywith ES2024. Older browsers or embedded engines may lack the recent ones; check withtypeofas we did. - Timings are single-machine medians; the quadratic shape is the result, not the exact milliseconds.
Exercises
1. Sort users by age, then by name, without changing the original array: [{ name: "Ken", age: 30 }, { name: "Ann", age: 25 }, { name: "Bo", age: 30 }].
2. const bad = [...matrix]; bad[0][0] = 9; changes matrix. Explain why and fix it for a 2-D array without a general deep copy.
Answers
1. users.toSorted((a, b) => a.age - b.age || a.name.localeCompare(b.name)) printed Ann,Bo,Ken, and the original order stayed Ken,Ann,Bo. When ages are equal, a.age - b.age is 0, which is falsy, so || falls through to the name comparison.
2. Spread copied the outer array, but the rows are shared references; matrix printed [[9,2],[3,4]]. Copy one level deeper: const good = m2.map(row => [...row]); left m2 as [[1,2],[3,4]] while good became [[9,2],[3,4]].
Next
Lecture 4 explains where toString and push come from: prototypes, class syntax, private #fields, getters and instanceof, plus what modules are and what jsc can and cannot run.
- Previous: JavaScript lecture 2/6: functions, scope and closures
- Next: JavaScript lecture 4/6: classes, prototypes and modules
- Later in the series: async, promises and the event loop, performance, memory and numbers
- Compare: Java streams, lambdas and Optional, Python types and mutability
- Where this fits: the programming learning roadmap
FAQ
Why does [10, 9, 1, 100].sort() put 100 before 9?
Without a comparator, sort() converts elements to strings and compares them character by character, so "100" comes before "9". Pass a comparator: arr.sort((a, b) => a - b). Remember that sort() also changes the original array; toSorted() (ES2023) returns a sorted copy.
Does the spread operator make a deep copy?
No. { ...obj } and [...arr] copy only the top level. Nested objects and arrays are shared, so changing copy.prefs.theme also changes original.prefs.theme. Use structuredClone where it exists (browsers, Node 17+; it was missing in the jsc we tested), JSON.parse(JSON.stringify(x)) for plain JSON data, or a small recursive copy.
Is it slow to use spread inside reduce?
Yes, it is quadratic. Building an object with reduce((acc, x) => ({ ...acc, [x.id]: x }), {}) copies the whole accumulator every step. For 8,000 items it took about 830 ms in our jsc run, while mutating the accumulator took 0.3 ms. Doubling the input roughly quadrupled the time.