Copying, Slicing and Flattening

Mental model: If the method name is a verb in the past tense (toSorted, toReversed), it hands you a copy. Otherwise assume it edits your array.

Level: intermediate · about 11 minutes

const a = ['a', 'b', 'c', 'd'];
console.log(a.slice(1, 3)); // ['b', 'c'], a copy
console.log(a);             // ['a','b','c','d'], untouched

const b = ['a', 'b', 'c', 'd'];
console.log(b.splice(1, 3)); // ['b', 'c', 'd'], the removed part
console.log(b);              // ['a'], gutted

Two methods, similar names, opposite behaviour.

slice(start, end) copies a section, with end exclusive, and never touches the original. splice(start, count) cuts a section out of the original. One letter apart in spelling, and the difference between a pure function and a mutation.

`slice()`
a shallow copy of the whole array
`slice(2)`
from index 2 to the end
`slice(1, 3)`
indexes 1 and 2, not 3
`slice(-2)`
the last two elements
`slice(0, -1)`
everything except the last
const original = [{ n: 1 }, { n: 2 }];
const shallow = [...original];
const deep = structuredClone(original);

shallow[0].n = 99;
console.log(original[0].n); // 99, same object

deep[1].n = 42;
console.log(original[1].n); // 2, untouched

Joining and flattening

console.log([1, 2].concat([3, 4], 5)); // [1,2,3,4,5]  (copies, and accepts loose values)
console.log([...[1, 2], ...[3, 4]]);   // [1,2,3,4], the modern spelling

console.log([1, [2, [3, [4]]]].flat());          // [1, 2, [3, [4]]]  (one level only)
console.log([1, [2, [3, [4]]]].flat(2));         // [1, 2, 3, [4]]
console.log([1, [2, [3, [4]]]].flat(Infinity));  // [1, 2, 3, 4]  (all the way down)
const orders = [
  { id: 1, items: ['pen', 'pad'] },
  { id: 2, items: ['ink'] },
];

console.log(orders.flatMap((o) => o.items)); // ['pen', 'pad', 'ink']

// Returning [] drops an element, returning two adds one.
console.log([1, 2, 3, 4].flatMap((n) => (n % 2 ? [n, n] : [])));
// [1, 1, 3, 3]

flatMap is map then flat(1), and it is the right tool for one-to-many.

Filling in place: fill and copyWithin

console.log(new Array(4).fill(0));        // [0, 0, 0, 0]
console.log([1, 2, 3, 4].fill(9, 1, 3));  // [1, 9, 9, 4]  (value, start, end)

const src = [1, 2, 3, 4, 5];
console.log(src.copyWithin(0, 3));        // [4, 5, 3, 4, 5]  (target, start)
console.log(src);                          // same array, mutated in place

fill and copyWithin both mutate. copyWithin is rare in everyday code and common in typed-array and buffer work, where allocating a new array would defeat the point.

console.log(['a', 'b', 'c'].join('-'));   // 'a-b-c'
console.log([1, 2, 3].join());            // '1,2,3', comma by default
console.log([1, null, undefined, 2].join('-')); // '1---2', empty for both
console.log([].join('-'));                // '', empty string, not an error

join turns an array into a string. Note what happens to holes and null.

The immutable twins

ES2023 added copying versions of the four mutating methods, so you no longer need [...arr] before every edit. They exist on arrays and typed arrays, and with is the one you will reach for most: it replaces a single index and returns a new array.

const days = ['mon', 'tue', 'wed'];

console.log(days.toReversed());        // ['wed', 'tue', 'mon']
console.log(days.with(1, 'TUE'));      // ['mon', 'TUE', 'wed']
console.log(days.toSpliced(1, 1));     // ['mon', 'wed']
console.log(days.toSpliced(1, 0, 'x')); // ['mon', 'x', 'tue', 'wed']
console.log(days);                      // ['mon','tue','wed'], all four copied
Mutates the arrayCopying alternativeNotes
push / pop[...arr, x] / arr.slice(0, -1)no toPushed exists
shift / unshiftarr.slice(1) / [x, ...arr]spread reads better anyway
splicetoSplicedsame arguments, returns the new array
sorttoSortedtakes the same comparator
reversetoReversedno more [...arr].reverse()
arr[i] = varr.with(i, v)accepts negative indexes
fill, copyWithinnonemutating by design
never mutatesslice, concat, map, filter, flat, flatMap, joinsafe by default
const nums = [1, 2, 3];
const copy = nums.slice();
copy.push(4);
console.log(nums.length, copy.length);

slice() with no arguments returns a shallow copy, so push on the copy cannot reach the original. Had you written const copy = nums there would be one array with two names and the answer would be 4 4.

Try it yourself

Reshape without mutating

const data = ['a', 'b', 'c', 'd'];

console.log(data.slice(1, 3));      // copy of a section
console.log(data.toReversed());     // copy, reversed
console.log(data.with(0, 'A'));     // copy, one index changed
console.log(data.toSpliced(1, 2));  // copy, section removed
console.log([...data, 'e']);        // copy, appended

console.log('still pristine:', data);

Rewrite each mutating line as a copying one, then check that data is still in its original state at the end.

Exercises

chunk

Write chunk(arr, size) that splits an array into groups of size, with the final group holding whatever is left over. Return [] if size is less than 1, and never mutate the input.

flattenDeep

Write flattenDeep(arr) that flattens an array of arbitrarily nested arrays into a single flat array, preserving order. Do it with recursion and Array.isArray rather than flat(Infinity), so you understand what flat is doing.

Check yourself

What does this log?
[2, 3] 4 — slice copies from index 1 up to but not including index 3, and leaves the source alone. Swap in splice(1, 3) and you get [2, 3, 4] back with a.length reduced to 1.
Which expression returns a new array with index 2 replaced, leaving the original alone?
arr.with(2, "x") — with is the copying single-index update from ES2023, and it accepts negative indexes too. The other three all modify the original array in place.
[[1, [2]], [3]].flat() gives you…
[1, [2], 3] — flat() defaults to a depth of one, so the outer brackets go and the inner [2] survives. Pass flat(Infinity) when you do not know how deep the nesting goes.

Common mistakes

  • Confusing slice with splice. One copies, the other cuts.
  • Assuming a spread or slice copy protects the objects inside. It does not.
  • Expecting flat() to flatten all the way down. The default depth is 1.

Takeaways

  • slice copies, splice mutates and returns what it removed.
  • All array copies are shallow. structuredClone for a deep one.
  • flat(depth) defaults to 1; flatMap handles one-to-many mapping in a single pass.
  • toSorted, toReversed, toSpliced and with are the copying twins worth memorising.