Arrays and Objects
Almost every data bug in a JavaScript codebase has the same shape: two names pointing at one object, and a change through one name surprising a reader of the other. This lesson is about seeing that shape before it costs you an afternoon.
References, and the bug this lesson exists to prevent
Objects and arrays are handed around by reference. Assigning one does not copy it; it makes a second name for the same thing.
const original = { name: 'Aisha', tags: ['php', 'sql'] };
const alias = original;
alias.name = 'Noshad';
console.log(original.name); // 'Noshad' - one object, two names
const copy = { ...original }; // a copy, one level deep
copy.name = 'Sam';
console.log(original.name); // 'Noshad' - the top level is safe
copy.tags.push('go');
console.log(original.tags); // ['php', 'sql', 'go'] - still shared
Open this example in the editor and change it. Nothing you type is saved.
The last three lines are the bug. { ...original } copied the top level, so name is independent - but tags was a reference, and copying a reference gives you a second reference to the same array. This is called a shallow copy, and every built-in copy in JavaScript is shallow: spread, Object.assign, Array.prototype.slice, Array.from.
Three ways to go deeper, in order of preference:
// 1. structuredClone - built in, handles Map, Set, Date, TypedArray,
// and cycles. Does not handle functions or class identity.
const deep = structuredClone(original);
// 2. Copy explicitly, at each level you care about. Verbose, but it
// says what it does and never copies more than you need.
const shaped = { ...original, tags: [...original.tags] };
// 3. JSON round trip - only for plain data, and it silently destroys
// Date (becomes a string), undefined, Infinity, NaN and functions.
const risky = JSON.parse(JSON.stringify(original));
Open this example in the editor and change it. Nothing you type is saved.
Reach for structuredClone by default. Reach for the explicit copy when the shape is small and known, which is most of the time in a well-designed module.
Arrays are objects with a length
An array is an object whose keys happen to be numeric strings, plus a length that the engine maintains. Knowing this explains several behaviours that otherwise look arbitrary.
const list = ['a', 'b', 'c'];
Object.keys(list); // ['0', '1', '2'] - strings
list[10] = 'z';
console.log(list.length); // 11 - length tracks the highest index
console.log(list[5]); // undefined - a hole, not a stored value
console.log(list); // ['a','b','c', <7 empty items>, 'z']
// Holes are not undefined values, and some methods skip them.
console.log(list.map((x) => 1)); // holes stay holes
console.log([...list].length); // 11 - spread fills them in
// length is writable, which is a truncation nobody expects.
list.length = 2;
console.log(list); // ['a', 'b']
Open this example in the editor and change it. Nothing you type is saved.
Sparse arrays are a corner you should stay out of rather than master. Build arrays with push, Array.from({ length: n }, fn) or a literal, never by assigning past the end.
Property order is specified, and it is not insertion order
Objects do have a defined key order, which surprises people who were told they do not.
const odd = { b: 1, 2: 2, a: 3, 1: 4 };
console.log(Object.keys(odd)); // ['1', '2', 'b', 'a']
Open this example in the editor and change it. Nothing you type is saved.
Integer-like keys come first, in ascending numeric order. Everything else follows in insertion order. Symbols come last and are skipped by Object.keys entirely.
This is why a Map is the right choice when order matters and keys are not all strings:
const counts = new Map();
counts.set('b', 1).set(2, 2).set('a', 3);
console.log([...counts.keys()]); // ['b', 2, 'a'] - true insertion order
Open this example in the editor and change it. Nothing you type is saved.
Use a Map when keys are dynamic, non-string, or numerous; use an object when the shape is known and fixed. A Map also has a real size, iterates without hasOwnProperty ceremony, and cannot collide with __proto__ or toString.
Reading safely
const response = { data: { items: [{ id: 1 }] } };
response.data?.items?.[0]?.id; // 1
response.meta?.page?.total; // undefined, no throw
response.meta?.page?.total ?? 0; // 0 - a usable default
// 'in' versus a truthiness test versus hasOwn:
const row = { value: 0, note: undefined };
row.value ? 'set' : 'unset'; // 'unset' - wrong, 0 is a value
'value' in row; // true - correct
Object.hasOwn(row, 'note'); // true - it exists, holds undefined
Object.hasOwn(row, 'toString'); // false - inherited, not own
'toString' in row; // true - in walks the prototype
Open this example in the editor and change it. Nothing you type is saved.
Object.hasOwn replaces Object.prototype.hasOwnProperty.call(obj, key), which was the long way of asking the same question safely.
A worked example
// Grouping, summing and shaping - the three things you actually do
// with arrays of records, done without mutating the input.
const orders = [
{ id: 1, customer: 'aisha', total: 1999, status: 'paid' },
{ id: 2, customer: 'noshad', total: 450, status: 'pending' },
{ id: 3, customer: 'aisha', total: 1299, status: 'paid' },
{ id: 4, customer: 'sam', total: 2500, status: 'paid' },
{ id: 5, customer: 'noshad', total: 199, status: 'paid' },
];
// 1. Group. Object.groupBy returns a null-prototype object, so a
// customer called 'constructor' cannot do any damage.
const byCustomer = Object.groupBy(orders, (o) => o.customer);
console.log(Object.keys(byCustomer)); // ['aisha', 'noshad', 'sam']
// 2. Summarise, without touching orders.
const summary = Object.entries(byCustomer).map(([customer, rows]) => ({
customer,
orders: rows.length,
paidPence: rows
.filter((o) => o.status === 'paid')
.reduce((sum, o) => sum + o.total, 0),
}));
// 3. Sort a COPY. toSorted leaves the original alone; sort would not.
const ranked = summary.toSorted((a, b) => b.paidPence - a.paidPence);
console.log(ranked);
// [ { customer: 'aisha', orders: 2, paidPence: 3298 },
// { customer: 'sam', orders: 1, paidPence: 2500 },
// { customer: 'noshad', orders: 2, paidPence: 199 } ]
// 4. Index for lookup. A Map, because ids are numbers and we want size.
const byId = new Map(orders.map((o) => [o.id, o]));
console.log(byId.get(3).customer); // 'aisha'
console.log(byId.size); // 5
// 5. Update one record immutably. The spread replaces the object at
// that index; every other element is the same reference, which is
// exactly what a framework's change detection wants.
const updated = orders.map((o) =>
o.id === 2 ? { ...o, status: 'paid' } : o);
console.log(orders[1].status); // 'pending' - untouched
console.log(updated[1].status); // 'paid'
console.log(updated[0] === orders[0]); // true - shared, deliberately
Open this example in the editor and change it. Nothing you type is saved.
The last line is the point of immutable updates. Only the changed object is new, so a comparison by reference tells a renderer exactly which row to redraw. Deep-cloning the whole array would also be correct and would throw that information away.
The copying methods, old and new
Four array methods now have non-mutating twins. Prefer the twin unless you have a reason.
| Mutates | Copies | Note |
|---|---|---|
sort() |
toSorted() |
Default sort is lexicographic - always pass a comparator for numbers |
reverse() |
toReversed() |
|
splice() |
toSpliced() |
|
arr[i] = v |
with(i, v) |
Returns a new array with one index changed |
const nums = [10, 9, 100, 1];
console.log(nums.toSorted()); // [1, 10, 100, 9] - strings!
console.log(nums.toSorted((a, b) => a - b)); // [1, 9, 10, 100]
console.log(nums); // unchanged
Open this example in the editor and change it. Nothing you type is saved.
push, pop, shift, unshift and fill have no copying twin, because spread covers them: [...list, item], list.slice(0, -1), [item, ...list].
When it goes wrong
| Symptom | Cause |
|---|---|
| Changing a copy changed the original | Spread and Object.assign are shallow |
A Date became a string after copying |
JSON round trip; use structuredClone |
An array has <n empty items> |
Assignment past the end created holes |
sort put 100 before 9 |
Default sort compares as strings |
| The original array changed after sorting | sort mutates; use toSorted |
A key called toString broke a lookup |
A plain object inherits from Object.prototype - use a Map |
| A React or Vue list did not re-render | The array was mutated, so its reference never changed |
obj.count was falsy at zero |
A truthiness test where in or ?? was meant |
A check you can run
Take the worked example, add orders[0].customer = 'CHANGED' at the very end, and print ranked. Nothing moves, because ranked holds strings that were copied out.
Now add updated[0].customer = 'CHANGED' instead, and print orders[0]. It changed, because updated[0] and orders[0] are the same object - deliberately, as the console log says. Being able to predict which of those two lines affects the original, before running it, is what this lesson is for.