Immutable Array Methods
Here’s a bug that has cost more engineer-hours than it has any right to:
const scores = [40, 100, 1, 5, 25, 10];
const top = scores.sort((a, b) => b - a);
// You wanted a sorted *copy*. You also mutated the original.
console.log(scores); // [100, 40, 25, 10, 5, 1] ← changed!
sort sorted scores in place and then handed you back the very same array. There is no separate “sorted copy” — top and scores are two names for one array. If some other part of your program still expected scores in its original order, it just silently broke.
The same trap hides in reverse (mutates, returns the same array) and splice (mutates, returns something else entirely — the removed elements, not the modified array). For years the workaround was to clone first: [...scores].sort(...). It works, but it’s a ritual you have to remember every single time, and forgetting it produces bugs that only show up two functions away from the mistake.
ES2023 added the fix directly to the language: four methods that do the same work but return a brand-new array and leave the original untouched — toSorted, toReversed, toSpliced, and with. The same release also shipped findLast and findLastIndex for searching from the end. All six are Baseline Widely available (in every major browser since mid-2023, and findLast since August 2022), so you can reach for them in production today without a polyfill.
Mutating vs. copying: the core distinction
Every array method falls into one of two camps. A mutating method reaches into the array you called it on and rewrites it. A copying method builds a fresh array and returns that, never touching the source.
The four new methods pair up one-to-one with the old mutators. Read this table as “when you want the result as a copy, reach for the right column”:
| Mutates in place | Returns a copy | What it does |
|---|---|---|
sort(cmp) |
toSorted(cmp) |
orders elements |
reverse() |
toReversed() |
flips order |
splice(start, count, ...items) |
toSpliced(start, count, ...items) |
remove/insert at an index |
arr[i] = v |
with(i, v) |
replace one element |
toSorted — sorting without the side effect
toSorted takes the same optional compare function as sort and returns a new, sorted array. The source stays in its original order.
const scores = [40, 100, 1, 5, 25, 10];
const ranked = scores.toSorted((a, b) => b - a);
console.log(ranked); // [100, 40, 25, 10, 5, 1]
console.log(scores); // [40, 100, 1, 5, 25, 10] ← untouched ✓
The compare function works exactly as before: return a negative number to put a first, positive to put b first, 0 to keep their relative order. And the classic gotcha of the default (string) sort still applies — with no compare function, elements are compared as strings, so [1, 5, 10].toSorted() gives [1, 10, 5]. Copying doesn’t change how it sorts, only what it does to the original.
Try it yourself. Edit the numbers, then sort a copy any way you like — the “original after” line proves the source list is never disturbed, no matter which sort you run.
toReversed — flip a copy
The simplest of the set. No arguments, returns a new array in reverse order:
const queue = ["a", "b", "c"];
const rev = queue.toReversed();
console.log(rev); // ["c", "b", "a"]
console.log(queue); // ["a", "b", "c"] ← untouched ✓
Compare that to the old approach, where the two-name trap bites again:
const queue = ["a", "b", "c"];
const rev = queue.reverse();
console.log(queue === rev); // true — they are the same array!
console.log(queue); // ["c", "b", "a"] — reversed in place
toSpliced — the return value finally makes sense
splice is the array multitool: remove, insert, or replace at any index. Its signature is splice(start, deleteCount, ...items). But it has a genuinely confusing return value — it hands back the elements it removed, not the modified array. People trip over this constantly:
const nums = [1, 2, 3, 4, 5];
const result = nums.splice(1, 2); // remove 2 elements starting at index 1
console.log(result); // [2, 3] ← the *removed* items, surprise
console.log(nums); // [1, 4, 5] ← the mutated array is over here
toSpliced takes the identical arguments but returns the resulting array and leaves the source alone. That is almost always what you actually wanted:
const nums = [1, 2, 3, 4, 5];
const without = nums.toSpliced(1, 2);
console.log(without); // [1, 4, 5] ← the new array ✓
console.log(nums); // [1, 2, 3, 4, 5] ← untouched ✓
It handles insertion and replacement too. Pass a deleteCount of 0 to insert without removing, or a non-zero count plus items to replace:
const months = ["Jan", "Mar", "Apr"];
months.toSpliced(1, 0, "Feb"); // ["Jan", "Feb", "Mar", "Apr"] (insert)
months.toSpliced(1, 1, "February"); // ["Jan", "February", "Apr"] (replace one)
months.toSpliced(0, 2); // ["Apr"] (delete two)
with — replace one element, get a copy
Assigning by index, arr[i] = value, is the most basic mutation there is. with(index, value) is its copying twin: it returns a new array identical to the original except for one slot.
const row = [10, 20, 30, 40];
const updated = row.with(2, 99);
console.log(updated); // [10, 20, 99, 40]
console.log(row); // [10, 20, 30, 40] ← untouched ✓
Two details worth knowing. First, with accepts negative indices, counting from the end — row.with(-1, 99) replaces the last element. Second, unlike bracket assignment (which happily grows the array when you write past the end), with throws a RangeError if the index is out of bounds. That’s a feature: it catches off-by-one mistakes instead of silently creating holes.
const row = [10, 20, 30];
row.with(-1, 99); // [10, 20, 99] — last element
row.with(5, 99); // RangeError: Invalid index : 5
Here it is live. Click any cell to replace it with the value in the box — with builds the copy while the original row stays exactly as it started.
Because with returns an array, you can chain it and keep going. This reads cleanly and never mutates anything along the way:
[1, 2, 3, 4, 5]
.with(0, 10) // [10, 2, 3, 4, 5]
.with(-1, 50) // [10, 2, 3, 4, 50]
.map(n => n * 2); // [20, 4, 6, 8, 100]
Why this matters: predictable state
If you’ve written React, Vue, Solid, or anything with a reactive store, you already know the golden rule: never mutate state directly, produce a new value. Frameworks detect change by comparing references. Mutate the array in place and the reference is identical before and after — so the framework sees “nothing changed” and skips the re-render.
// ❌ Bug: same reference, React may not re-render
setItems(prev => {
prev.sort((a, b) => a - b); // mutates the existing state array
return prev; // same reference as before
});
// ✅ New reference every time — the update is detected
setItems(prev => prev.toSorted((a, b) => a - b));
This is where the copying methods shine. Before ES2023 you had to spread-then-mutate — [...prev].sort(...), [...prev].reverse(), [...prev.slice(0, i), value, ...prev.slice(i + 1)] for a single replacement. That last one is genuinely awful to read. Compare:
// Old: replace one item immutably
const next = [...prev.slice(0, i), value, ...prev.slice(i + 1)];
// New: says exactly what it means
const next = prev.with(i, value);
findLast and findLastIndex — search from the end
find and findIndex scan an array from the front and stop at the first match. Their ES2023 companions, findLast and findLastIndex, do the same job but scan from the back, returning the last match instead.
You could always reverse-and-find, but that’s wasteful (it builds a whole reversed array) and it scrambles the index math. Scanning from the end directly is cleaner and often faster, because it stops as soon as it finds a match near the tail.
const readings = [5, 12, 50, 130, 44];
readings.findLast(n => n > 45); // 130 (value of last match)
readings.findLastIndex(n => n > 45); // 3 (its index)
readings.find(n => n > 45); // 50 (first match, for contrast)
readings.findIndex(n => n > 45); // 2
The callback receives the usual (element, index, array). When nothing matches, findLast returns undefined and findLastIndex returns -1 — same sentinels as their forward siblings.
Change the threshold below and watch the two matches move independently: find locks onto the first cell that passes (highlighted from the left), findLast onto the last (from the right).
Where does searching from the end actually come up? More often than you’d think:
- The most recent entry in an append-only log:
events.findLast(e => e.type === "error"). - The last item matching a filter in an ordered list, like the final completed step in a wizard.
- Parsing, where you want the last delimiter or the last valid token before some boundary.
Stepping through the difference
To make the mutation-vs-copy split concrete, here’s the exact moment a shared reference bites, versus the copying method sidestepping it.
Edge cases and gotchas worth remembering
A few behaviors that will save you a debugging session:
Sparse arrays become dense. All four copying methods read empty slots as undefined and write real undefined values into the result. A hole goes in, an undefined comes out.
console.log([1, , 3].toReversed()); // [3, undefined, 1] — the hole is filled
with and toSpliced throw or clamp on bad indices differently. with throws a RangeError for any index outside -length … length-1. toSpliced, like splice, clamps — a start beyond the end just appends, a negative start counts from the end, and one too negative is treated as 0. Don’t assume they validate the same way.
They’re generic. Like most array methods, these work on array-like objects via .call, e.g. Array.prototype.toSorted.call({ length: 2, 0: "b", 1: "a" }). Rarely needed, but it explains why they don’t strictly require a real array.
Summary
- Old
sort,reverse, andsplicemutate in place.sort/reversealso return the same array (an aliasing trap), andsplicereturns the removed elements, not the modified array. - ES2023 added copying twins that return a fresh array and leave the source untouched:
toSorted,toReversed,toSpliced, andwith. with(index, value)replaces one element by index, supports negative indices, and throwsRangeErrorwhen out of bounds — cleaner than the old slice-spread-slice pattern.findLastandfindLastIndexsearch from the end, returning the last match (orundefined/-1). Prefer them overfilter(...).at(-1)when you only need one element.- These methods make shallow copies — nested objects are still shared. Copy the level you mutate.
- This is the backbone of predictable state in reactive frameworks: a new reference is what tells them something changed.
- All six are Baseline Widely available (mid-2023 for the four copiers, 2022 for the finders) — safe to use directly in current browsers and Node 20+.