JavaScript (ES2026) Module 6 – Arrays and array methods
Copying array methods: toSorted, toSpliced, with
Change JavaScript arrays without mutating them: toSorted, toReversed, toSpliced and with() return new arrays, while push, sort and splice change the original.
What you will learn
- Replace copy-then-mutate code with toSorted, toReversed, toSpliced and with
- List the array methods that change the array they are called on
- Update state with copying methods while unchanged items stay shared
- Estimate what a copy costs and avoid one copy per item in a loop
- Predict how the copying methods treat holes, subclasses and typed arrays
Before you start
What you’ll learn · Before you start
What you will learn
- Replace copy-then-mutate code with toSorted, toReversed, toSpliced and with
- List the array methods that change the array they are called on
- Update state with copying methods while unchanged items stay shared
- Estimate what a copy costs and avoid one copy per item in a loop
- Predict how the copying methods treat holes, subclasses and typed arrays
Before you start
On this page
ECMAScript 2023 added four array methods that return a changed copy and leave the original array alone.
toSorted() and toReversed() are the copying forms of sort() and reverse(). toSpliced(start, skipCount, ...items) removes and inserts items like splice(), but returns the new array instead of the removed items.
with(index, value) returns a copy with one item replaced, the copying form of list[index] = value. Reach for them
whenever other code still holds the array: state that a screen compares with its previous version, data that several
functions share, or input that a function must not change. Nine methods change the array they are called on: push,
pop, shift, unshift, splice, sort, reverse, fill and copyWithin. Every copy costs time and memory in
proportion to the array’s length, so one copy per change is cheap, and one copy per item inside a loop is not.
The four copying methods
const queue = ['Ravi', 'Asha', 'Meena', 'Kabir'];
console.log(queue.toSorted()); // a sorted copy, like sort()
console.log(queue.toReversed()); // a reversed copy, like reverse()
console.log(queue.toSpliced(1, 2, 'Dev')); // remove 2 items at index 1, insert 'Dev'
console.log(queue.with(0, 'Zoya')); // replace the item at index 0
console.log(queue); // the original, untouched by all four
// splice() changes the array and returns what it removed; toSpliced() returns the new array.
const board = [...queue];
console.log(board.splice(1, 2, 'Dev'), board); Output
[ 'Asha', 'Kabir', 'Meena', 'Ravi' ] [ 'Kabir', 'Meena', 'Asha', 'Ravi' ] [ 'Ravi', 'Dev', 'Kabir' ] [ 'Zoya', 'Asha', 'Meena', 'Kabir' ] [ 'Ravi', 'Asha', 'Meena', 'Kabir' ] [ 'Asha', 'Meena' ] [ 'Ravi', 'Dev', 'Kabir' ]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node four-methods.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Each call returned a new array, and the last line but one shows queue exactly as it started:
toSorted(compare)takes the same comparator assort(), with the same rules, and is just as stable. With no comparator it compares items as strings, assort()does.toReversed()returns the items in the opposite order.toSpliced(start, skipCount, ...items)skipsskipCountitems fromstartand putsitemsin their place.toSpliced(1, 2, 'Dev')dropped Asha and Meena and inserted Dev.with(index, value)replaces the item at one index.
The last line shows the difference that trips people up when they switch: splice() returns the items it removed
and changes board, while toSpliced() returns the whole new array. Code that read the removed items from
splice() must take them with slice(start, start + skipCount) before it changes to toSpliced().
with(): replace one item
const seats = ['A1', 'A2', 'A3'];
// A negative index counts from the end, as with at().
console.log(seats.with(-1, 'A3 window'));
// An index outside the array is an error, not a new slot.
try {
seats.with(3, 'A4');
} catch (error) {
console.log(error.name);
}
// Assigning with brackets on a copy grows the array instead, and leaves holes.
const copy = [...seats];
copy[5] = 'A6';
console.log(copy, copy.length); Output
[ 'A1', 'A2', 'A3 window' ] RangeError [ 'A1', 'A2', 'A3', <2 empty items>, 'A6' ] 6
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node with-index.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
with() accepts a negative index, which counts back from the end as at() does, so with(-1, …) replaces the last
item. An index outside the array throws a RangeError: with() replaces an item and never adds one. Assigning to
an index past the end with brackets behaves differently: the array grows, and the indexes in between become holes.
That RangeError is useful, because a wrong index fails at once instead of leaving a gap that shows up later.
Which methods change the array
The surest way to learn which methods change an array is to freeze one and let it refuse every change:
// A frozen array refuses every change, so each method that tries to change it throws a TypeError.
const frozen = Object.freeze(['b', 'c', 'a']);
const methods = {
push: (a) => a.push('d'),
pop: (a) => a.pop(),
shift: (a) => a.shift(),
unshift: (a) => a.unshift('z'),
splice: (a) => a.splice(0, 1),
sort: (a) => a.sort(),
reverse: (a) => a.reverse(),
fill: (a) => a.fill('x'),
copyWithin: (a) => a.copyWithin(0, 1),
toSorted: (a) => a.toSorted(),
toReversed: (a) => a.toReversed(),
toSpliced: (a) => a.toSpliced(0, 1),
with: (a) => a.with(0, 'z'),
slice: (a) => a.slice(1),
concat: (a) => a.concat(['d']),
map: (a) => a.map((s) => s.toUpperCase()),
filter: (a) => a.filter((s) => s !== 'c'),
};
for (const [name, call] of Object.entries(methods)) {
try {
console.log(`${name.padEnd(10)} returns ${JSON.stringify(call(frozen))}`);
} catch (error) {
console.log(`${name.padEnd(10)} throws a ${error.name}: it changes the array`);
}
}
console.log('the frozen array is still', JSON.stringify(frozen)); Output
push throws a TypeError: it changes the array pop throws a TypeError: it changes the array shift throws a TypeError: it changes the array unshift throws a TypeError: it changes the array splice throws a TypeError: it changes the array sort throws a TypeError: it changes the array reverse throws a TypeError: it changes the array fill throws a TypeError: it changes the array copyWithin throws a TypeError: it changes the array toSorted returns ["a","b","c"] toReversed returns ["a","c","b"] toSpliced returns ["c","a"] with returns ["z","c","a"] slice returns ["c","a"] concat returns ["b","c","a","d"] map returns ["B","C","A"] filter returns ["b","a"] the frozen array is still ["b","c","a"]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node mutation-audit.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Object.freeze() makes every index and the length of an array read-only, so each method that writes to the array
throws a TypeError. The nine methods that threw are the mutating ones. Everything else in the list built a new array
and left the frozen one as it was. Each mutating method has a copying way to get the same result:
| Changes the array | Copying form | Note |
|---|---|---|
sort(compare) |
toSorted(compare) |
same comparator, same stable order |
reverse() |
toReversed() |
|
splice(start, count, ...items) |
toSpliced(start, count, ...items) |
splice returns the removed items, toSpliced the new array |
list[i] = value |
with(i, value) |
with throws a RangeError outside the array |
push(...items) |
[...list, ...items] or concat(items) |
|
unshift(...items) |
[...items, ...list] |
|
pop(), shift() |
slice(0, -1), slice(1) |
read the item first with at(-1) or at(0) |
fill(value) |
Array.from(list, () => value) |
Object.freeze() is also a cheap guard in tests: freeze the input, and any accidental change throws at the line that
made it. It is shallow, though. A frozen array of objects still lets those objects change, so freeze the items as
well when they matter, as the exercise below does.
Updating state without changing it
Copying methods suit state that is replaced as a whole on every update, as a reducer replaces it. Here a to-do list goes through three updates:
// A to-do list kept as state: every update returns a new array and never changes the old one.
const todos = Object.freeze([
Object.freeze({ text: 'Pay electricity bill', done: false }),
Object.freeze({ text: 'Book train tickets', done: false }),
Object.freeze({ text: 'Renew bus pass', done: false }),
]);
// Mark item 1 done: a new array whose item 1 is a new object.
const ticked = todos.with(1, { ...todos[1], done: true });
// Insert after item 0, and remove item 2.
const added = ticked.toSpliced(1, 0, { text: 'Call the bank', done: false });
const removed = added.toSpliced(3, 1);
for (const t of removed) console.log(t.done ? '[x]' : '[ ]', t.text);
// What was copied: the array always, an item only when it changed.
console.log('new array:', ticked !== todos);
console.log('item 0 is the same object:', ticked[0] === todos[0]);
console.log('item 1 is a new object:', ticked[1] !== todos[1]);
console.log('the old list still says:', todos.map((t) => t.done).join(', ')); Output
[ ] Pay electricity bill [ ] Call the bank [x] Book train tickets new array: true item 0 is the same object: true item 1 is a new object: true the old list still says: false, false, false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node state-update.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
with(1, { ...todos[1], done: true }) builds a new array and a new object for the item that changed. It does not
copy the other items: the new array points at the same objects as the old one.
What todos.with(1, changed) copies
Text description of the diagram
Five boxes, one above the other.
- The old array, todos, holds item 0, item 1 and item 2.
- todos.with(1, changed) is called, where changed is a new object made with { ...item 1, done: true }.
- It copies the list into a new array: item 0, changed, item 2.
- Item 0 and item 2 are not copied: the new array points at the same objects as the old one.
- todos is unchanged and still holds the original item 1, so code that kept the old array still sees the old list.
The copy is shallow: an array of n items costs n new slots, while the item objects themselves are shared.
That sharing is what makes copying affordable: an update copies the list of references, one slot per item, and
creates new objects only for the items that changed. It also means that the copy is shallow. If code changes a
shared item in place, both lists see the change, so a change to an item must also be made as a copy, with
{ ...item, done: true } or a similar new object.
What a copy costs
Each copying method reads every item of the array and writes a new array of about the same length. One copy for one update is cheap. One copy for each item, inside a loop, is not:
// 10,000 numbers, each to be doubled.
const values = Array.from({ length: 10000 }, (_, i) => i);
function time(label, run) {
const start = performance.now();
const result = run();
console.log(`${label}${(performance.now() - start).toFixed(1)} ms (last item ${result.at(-1)})`);
}
// with() copies the whole array for every single change: 10,000 copies of 10,000 items.
time('with() for each item: ', () => {
let current = values;
for (let i = 0; i < current.length; i++) current = current.with(i, current[i] * 2);
return current;
});
// map() builds one new array with every change in it.
time('one map(): ', () => values.map((n) => n * 2));
// Or copy once and change the copy, which nobody else can see yet.
time('one copy, then a loop: ', () => {
const copy = values.slice();
for (let i = 0; i < copy.length; i++) copy[i] *= 2;
return copy;
}); Output
with() for each item: 32.3 ms (last item 19998) one map(): 0.2 ms (last item 19998) one copy, then a loop: 0.3 ms (last item 19998)
This output changes from run to run: the times depend on the computer and on what else it is running
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node copy-cost.mjs
Calling with() once per item makes 10,000 copies of a 10,000-item array: 100 million items written to change
10,000. On an Apple M5 Pro with Node.js 24.21.0 that took tens of milliseconds, against well under a millisecond for
one map(), and the gap grows with the square of the length: with 50,000 items the same loop writes 2.5 billion
items, and it took 21 seconds, while one map() took 9 milliseconds. When many items change at once, build the new
array in one pass with map(), or copy once with slice() and change the copy, which is safe because no other code
holds it yet.
Holes, subclasses, typed arrays and array-likes
// 1. Holes: the copy methods read every index, so a hole comes back as undefined.
const sparse = [1, , 3];
console.log(sparse.toReversed(), sparse.with(0, 9));
// 2. Subclasses: the copy methods always build a plain Array.
class Playlist extends Array {}
const songs = Playlist.from(['Night Train', 'Monsoon Song', 'Morning Raga']);
console.log(songs.toSorted() instanceof Playlist, songs.slice() instanceof Playlist);
// 3. Typed arrays have toSorted, toReversed and with, but no toSpliced: their length is fixed.
const levels = new Uint8Array([30, 10, 20]);
console.log(levels.toSorted(), typeof levels.toSpliced);
// with() converts the new value to the element type: 300 does not fit in a byte.
console.log(levels.with(0, 300));
// 4. Array-likes: the methods are generic, and the result is a real array.
const arrayLike = { length: 3, 0: 'c', 1: 'a', 2: 'b' };
console.log(Array.prototype.toSorted.call(arrayLike)); Output
[ 3, undefined, 1 ] [ 9, undefined, 3 ] false true Uint8Array(3) [ 10, 20, 30 ] undefined Uint8Array(3) [ 44, 10, 20 ] [ 'a', 'b', 'c' ]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node edge-cases.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Four details follow from how the standard defines these methods:
- Holes become
undefined. The copying methods read every index from 0 to the length, so a hole in the original comes back asundefined, and the new array has no holes.sort()andreverse()keep holes as holes. - The result is always a plain
Array. A subclass such asPlaylistgets a plain array back fromtoSorted(), whileslice(),map()andfilter()build an instance of the subclass. - Typed arrays have three of the four.
Uint8Arrayand the other typed arrays havetoSorted(),toReversed()andwith(), but notoSpliced(), just as they have nosplice().with()converts the new value to the element type, so 300 stored in a byte becomes 44 (300 − 256). - They work on array-likes. Like most array methods, the copying methods only need a
lengthand indexes, soArray.prototype.toSorted.call(arrayLike)returns a real, sorted array.
Where they run
The four methods are Baseline Widely available: Chrome and Edge 110, Firefox 115 and Safari 16 support them, and so
do Node.js 20 and later. The Run buttons of this track use QuickJS, which has them too. Code that must still support
older engines can fall back on copying first: [...list].sort(compare) for toSorted, [...list].reverse() for
toReversed, and a slice() copy changed with splice() or an index assignment for the other two.
Exercise: a to-do reducer that copies
Exercise · Medium · JavaScript
Rewrite a to-do reducer with the copying methods
todos(state, action) takes a list of to-dos such as [{ text: 'Pay rent', done: false }] and an action, and returns the next list. The starter code works, but it changes the list it is given, so a screen that compares the old and the new list never sees a change. Rewrite it so that it **never changes state or any item in it**:
{ type: 'add', text }adds{ text, done: false }at the end;{ type: 'toggle', index }flipsdoneof the item atindex(a negative index counts from the end, and an index outside the list throws aRangeError);{ type: 'remove', index }removes the item atindex;{ type: 'move', from, to }takes the item atfromout and puts it back so that it ends up at indexto;{ type: 'doneLast' }puts the items that are done after the ones that are not, keeping the order inside each group;- any other action returns
stateitself.
Items that an action does not change must stay the very same objects in the new list. The tests freeze state and its items, so any change to them throws a TypeError.
const list = [{ text: 'Pay rent', done: false }, { text: 'Buy milk', done: true }];
todos(list, { type: 'toggle', index: 0 })
// [{ text: 'Pay rent', done: true }, { text: 'Buy milk', done: true }], and list is unchangedStarter code · todos.mjs
/**
* The next to-do list after `action`. Works, but changes `state`: rewrite it with the copying methods so that
* `state` and its items are never changed.
*/
export function todos(state, action) {
switch (action.type) {
case 'add':
state.push({ text: action.text, done: false });
return state;
case 'toggle': {
const index = action.index < 0 ? state.length + action.index : action.index;
if (index < 0 || index >= state.length) throw new RangeError(`no item at index ${action.index}`);
state[index].done = !state[index].done;
return state;
}
case 'remove':
state.splice(action.index, 1);
return state;
case 'move': {
const [item] = state.splice(action.from, 1);
state.splice(action.to, 0, item);
return state;
}
case 'doneLast':
return state.sort((a, b) => Number(a.done) - Number(b.done));
default:
return state;
}
} The sample tests · todos.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { todos } from './todos.mjs';
const item = (text, done = false) => Object.freeze({ text, done });
const list = (...items) => Object.freeze(items);
const texts = (state) => state.map((t) => (t.done ? `${t.text} (done)` : t.text));
test('add puts a new item at the end and leaves state alone', () => {
const state = list(item('Pay rent'));
const next = todos(state, { type: 'add', text: 'Buy milk' });
assert.deepEqual(texts(next), ['Pay rent', 'Buy milk']);
assert.equal(next[1].done, false);
assert.equal(state.length, 1);
assert.equal(next[0], state[0], 'the old item is the same object');
});
test('toggle flips one item and keeps the others as they are', () => {
const state = list(item('Pay rent'), item('Buy milk', true), item('Call home'));
const next = todos(state, { type: 'toggle', index: 1 });
assert.deepEqual(texts(next), ['Pay rent', 'Buy milk', 'Call home']);
assert.notEqual(next, state);
assert.notEqual(next[1], state[1], 'the toggled item is a new object');
assert.equal(next[0], state[0]);
assert.equal(next[2], state[2]);
assert.equal(state[1].done, true, 'the old item still says done');
assert.deepEqual(texts(todos(next, { type: 'toggle', index: -1 })), ['Pay rent', 'Buy milk', 'Call home (done)']);
});
test('toggle outside the list throws a RangeError', () => {
const state = list(item('Pay rent'));
assert.throws(() => todos(state, { type: 'toggle', index: 1 }), RangeError);
assert.throws(() => todos(state, { type: 'toggle', index: -2 }), RangeError);
});
test('remove takes out one item', () => {
const state = list(item('A'), item('B'), item('C'));
assert.deepEqual(texts(todos(state, { type: 'remove', index: 1 })), ['A', 'C']);
assert.deepEqual(texts(todos(state, { type: 'remove', index: 0 })), ['B', 'C']);
assert.deepEqual(texts(state), ['A', 'B', 'C']);
});
test('move puts an item at its new index', () => {
const state = list(item('A'), item('B'), item('C'), item('D'));
assert.deepEqual(texts(todos(state, { type: 'move', from: 0, to: 2 })), ['B', 'C', 'A', 'D']);
assert.deepEqual(texts(todos(state, { type: 'move', from: 3, to: 0 })), ['D', 'A', 'B', 'C']);
const moved = todos(state, { type: 'move', from: 1, to: 3 });
assert.deepEqual(texts(moved), ['A', 'C', 'D', 'B']);
assert.equal(moved[3], state[1], 'a move keeps the same item object');
});
test('doneLast keeps the order inside each group', () => {
const state = list(item('A', true), item('B'), item('C', true), item('D'));
assert.deepEqual(texts(todos(state, { type: 'doneLast' })), ['B', 'D', 'A (done)', 'C (done)']);
assert.deepEqual(texts(state), ['A (done)', 'B', 'C (done)', 'D']);
});
test('an unknown action returns state itself', () => {
const state = list(item('A'));
assert.equal(todos(state, { type: 'rename' }), state);
}); A hint
Use one copying method per action: toSpliced to add, remove or move (a move is a removal followed by an insertion), with to put a changed copy of one item in place, built with { ...item, done: !item.done }, and toSorted for doneLast. with already throws a RangeError for an index outside the list.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Self-check
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What is the difference between splice() and toSpliced()? Both remove a number of items from a start index
and can insert new items in their place, with the same arguments: start, how many to remove, then the items to
insert. splice() changes the array it is called on and returns an array of the items it removed. toSpliced(),
added in ECMAScript 2023, leaves the original alone and returns a new array with the removal and insertion applied,
reading any holes as undefined. So list.splice(1, 1) gives you the removed item and a shorter list, while
list.toSpliced(1, 1) gives you a shorter copy and an unchanged list. Use toSpliced() for shared data and state
that must not change; use splice() on an array that only your code holds, when the removed items are what you need.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An import screen applies one fix per row with rows = rows.with(i, fixRow(rows[i])) in a loop, to keep state immutable. With 50,000 rows the tab freezes for seconds, and a teammate concludes that the copying methods are too slow to use. Explain the cost with numbers, rewrite the update, and say when with() is the right tool.
-
Fresher to mid
A settings page shows the user's saved addresses sorted with addresses.toSorted(byCity). When the user edits an address in the sorted view with sorted[i].city = 'Pune', the saved list changes too, and the undo button stops working. Explain why, and fix the edit so that the saved list stays as it was.
Key takeaways
toSorted(),toReversed(),toSpliced()andwith()return a new array and never change the original.push,pop,shift,unshift,splice,sort,reverse,fillandcopyWithinchange the array they are called on; a frozen array makes each of them throw aTypeError.splice()returns the removed items;toSpliced()returns the new array.with()accepts negative indexes and throws aRangeErroroutside the array, instead of growing it.- The copies are shallow: unchanged items are shared, so change an item by replacing it with a new object.
- Each copy costs time in proportion to the length: use one
map()or one private copy for many changes, never onewith()per item. - Holes come back as
undefined, subclasses get a plainArray, and typed arrays have every method buttoSpliced().
References
- ECMAScript 2026 Language Specification: Array.prototype.toSorted (Ecma International)
- ECMAScript 2026 Language Specification: Array.prototype.toReversed (Ecma International)
- ECMAScript 2026 Language Specification: Array.prototype.toSpliced (Ecma International)
- ECMAScript 2026 Language Specification: Array.prototype.with (Ecma International)
- ECMAScript 2026 Language Specification: %TypedArray%.prototype.with (Ecma International)
- ECMAScript 2026 Language Specification: Object.freeze (Ecma International)
- Change Array by Copy (proposal) (Ecma International (TC39))
- Array.prototype.with() (MDN Web Docs)
- web-features: array-by-copy (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress