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 that undefined. JavaScriptCore names the whole failing expression (user.address.city); Chrome and Node say Cannot read properties of undefined (reading 'city') (not run here).
  • const freezes the binding, not the object. Adding and deleting properties on a const object is fine.
  • in looks up the prototype chain (so "toString" in user is true), Object.hasOwn only checks the object itself. Prototypes are lecture 4.
  • Optional chaining a?.b stops and returns undefined if a is null or undefined. 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
  • filter keeps the elements whose callback returns a truthy value; map turns each element into a new one. Both return a new array and leave the original alone.
  • reduce folds the array into one value. The second argument is the starting accumulator.
  • find/findIndex stop at the first match; some/every answer 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() gave 1,2,3, [1, 2].flatMap(x => [x, x * 10]) gave 1,10,2,20, and [1, 2, 3].at(-1) gave 3.

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.

Decision diagram for copying data. Question one: does it contain nested objects or arrays that you will change? If no, a shallow copy with spread, { ...obj } or [...arr], is enough. If yes, question two: is it plain JSON data with no Date, Map, undefined or BigInt? If yes, JSON.parse(JSON.stringify(x)) works. If no, use structuredClone in browsers and Node 17 or later, or a small recursive deepClone in engines like jsc that lack it.
Choosing a copy. Spread is enough only when nothing nested will be edited; JSON works only for plain JSON data; this jsc has no structuredClone.

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 Date came back as a string, the undefined property disappeared, the Map became {}, and a BigInt throws.
  • structuredClone is the modern answer in browsers and Node 17+ (not run here). It is defined by the HTML standard, not by ECMAScript, and this jsc does 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:

  1. Default sort() compares strings, so "100" < "25" < "9". Numbers need (a, b) => a - b (negative means a first).
  2. sort() mutates and returns the same array (same array: true). toSorted() (ES2023) returns a copy and left nums untouched.
  3. Strings sort by UTF-16 code unit, so capital C comes before a, and ä lands at the end. localeCompare gives a human order.
  4. undefined always goes last, and null is compared as the string "null", so it lands after 3. (join prints both as empty strings, hence 1|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.

Bar chart of object spread inside reduce. 1,000 items took 13.9 milliseconds, 2,000 items 57.5 milliseconds, 4,000 items 195.5 milliseconds and 8,000 items 826.1 milliseconds, roughly four times longer each time the input doubles. The mutating version stayed under 0.3 milliseconds.
Building an index with ({ ...acc, [id]: item }) inside reduce, median of 5 runs, jsc on macOS 26.6, Apple M1 Pro. Doubling N roughly quadruples the time; mutating the accumulator took 0.3 ms at N = 8,000.

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) and console. 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.fromEntries and stable sort with ES2019, ?. and ?? with ES2020, at and Object.hasOwn with ES2022, toSorted/toReversed/with/findLast with ES2023, Object.groupBy with ES2024. Older browsers or embedded engines may lack the recent ones; check with typeof as 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.

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.

  • #javascript
  • #intermediate
  • #arrays
  • #objects
  • #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.

About · Apps

↑