Arrays and Objects

Article · 10:00

Both are objects and both are held by reference. That one fact explains the first genuinely confusing bug most people write: a copy that was not a copy, and a change in one place showing up in another.

This lesson has 8 runnable examples. Press Try yourself! under any of them to open it in the editor.

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.

JS Example 1 12 lines
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:

JS Example 2 11 lines
// 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.

JS Example 3 14 lines
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.

JS Example 4 2 lines
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:

JS Example 5 3 lines
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

JS Example 6 13 lines
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

JS Example 7 45 lines
// 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
JS Example 8 4 lines
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.