Mutating arrays
Some array operations change the array you called them on. The rest return a new one and leave the original alone.
Exactly eleven operations change it: push, pop, shift, unshift at the ends; splice in the middle; sort and reverse rearranging; fill and copyWithin overwriting; assignment to an index, and assignment to length. Everything else on an array returns something new. Eleven names is a short enough list to know, and knowing it means never having to guess. Since ES2023 each of the destructive methods has a non-destructive twin, toSorted, toReversed, toSpliced, and with, so the distinction is now a choice rather than a constraint, and the twin should be your default.
Create programs/mutating-arrays.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
the four moves at the ends
Deno.test("the four moves at the ends", () => {
const queue = ["b", "c"];
queue.push("d");
queue.unshift("a");
assertEquals(queue, ["a", "b", "c", "d"]);
assertStrictEquals(queue.pop(), "d");
assertStrictEquals(queue.shift(), "a");
assertEquals(queue, ["b", "c"]);
});
Check programs/mutating-arrays.test.ts
running 1 test from ./programs/mutating-arrays.test.ts
the four moves at the ends ... ok (304µs)
ok | 1 passed | 0 failed (1ms)
Four methods, two at each end, and they are how one array serves as a list, a stack, and a queue. Two are worth remembering and the other two follow: push appends, and is how you assemble a result; shift takes from the front, and is how you consume one. pop is the inverse of push and unshift is the inverse of shift.
the same moves, leaving the original alone
Deno.test("the same moves, leaving the original alone", () => {
const original = ["b", "c"];
assertEquals([...original, "d"], ["b", "c", "d"]);
assertEquals(["a", ...original], ["a", "b", "c"]);
assertEquals(original.slice(0, -1), ["b"]);
assertEquals(original.slice(1), ["c"]);
assertEquals(original, ["b", "c"]);
const kept = ["a", "b", "c"];
assertEquals(kept.with(1, "x"), ["a", "x", "c"]);
assertEquals(kept, ["a", "b", "c"]);
});
the same moves, leaving the original alone ... ok (70µs)
Spread to add at either end, slice to drop from either end, and with for setting one element without touching the original. Note that these give you the resulting array, where pop and shift give you the removed element; when you want both, the destructive pair is genuinely shorter.
what each destructive method hands back
Sort into a new name, and predict what the old name holds:
Deno.test("what each destructive method hands back", () => {
assertStrictEquals(["a"].push("b", "c"), 3);
assertStrictEquals(["a"].unshift("z"), 2);
const spliced = ["a", "b", "c", "d"];
assertEquals(spliced.splice(1, 2, "x"), ["b", "c"]);
assertEquals(spliced, ["a", "x", "d"]);
const same = [3, 1, 2];
assertStrictEquals(same.sort((a, b) => a - b), same);
assertStrictEquals(same.reverse(), same);
assertStrictEquals(same.fill(0), same);
assertStrictEquals(same.copyWithin(0, 1), same);
assertStrictEquals(([] as string[]).pop(), undefined);
assertStrictEquals(([] as string[]).shift(), undefined);
const rows = ["b", "c", "a"];
const sorted = rows.sort();
assertEquals(sorted, ["a", "b", "c"]);
assertEquals(rows, ["b", "c", "a"]);
});
Check programs/mutating-arrays.test.ts
running 3 tests from ./programs/mutating-arrays.test.ts
...
what each destructive method hands back ... FAILED (9ms)
ERRORS
what each destructive method hands back => ./programs/mutating-arrays.test.ts:30:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
- "a",
"b",
"c",
+ "a",
]
FAILURES
what each destructive method hands back => ./programs/mutating-arrays.test.ts:30:6
FAILED | 2 passed | 1 failed (9ms)
error: Test failed
The original order is gone, because sorted and rows are one array. Three different return values in this step, and none of them is the copy you might have wanted. push and unshift return the new length, which is almost never useful and easy to mistake for the array. splice returns what it removed, which is useful and surprising. And sort, reverse, fill, and copyWithin return the same array they just changed, which is the most dangerous of the three, because it makes a destructive call look like a non-destructive one: the ordering and sorting page calls that a bug at a distance. Correct the prediction and pin the identity:
Deno.test("what each destructive method hands back", () => {
assertStrictEquals(["a"].push("b", "c"), 3);
assertStrictEquals(["a"].unshift("z"), 2);
const spliced = ["a", "b", "c", "d"];
assertEquals(spliced.splice(1, 2, "x"), ["b", "c"]);
assertEquals(spliced, ["a", "x", "d"]);
const same = [3, 1, 2];
assertStrictEquals(same.sort((a, b) => a - b), same);
assertStrictEquals(same.reverse(), same);
assertStrictEquals(same.fill(0), same);
assertStrictEquals(same.copyWithin(0, 1), same);
assertStrictEquals(([] as string[]).pop(), undefined);
assertStrictEquals(([] as string[]).shift(), undefined);
const rows = ["b", "c", "a"];
const sorted = rows.sort();
assertEquals(sorted, ["a", "b", "c"]);
assertEquals(rows, ["a", "b", "c"]);
assertStrictEquals(sorted, rows);
});
what each destructive method hands back ... ok (62µs)
The general rule: if a method changed the array, whatever it returned is not a copy. Note the empty-array pair too: pop and shift on an empty array return undefined rather than complaining, so a loop that drains an array needs to check the length rather than the result, unless undefined cannot be an element.
readonly removes exactly those eleven
Write all eleven mutations against a readonly string[], in a scratch file programs/readonly-array.ts:
const lines: readonly string[] = ["b", "a"];
lines.push("c");
lines.pop();
lines.shift();
lines.unshift("z");
lines.splice(0, 1);
lines.sort();
lines.reverse();
lines.fill("x");
lines.copyWithin(0, 1);
lines[0] = "z";
lines.length = 0;
Check programs/readonly-array.ts
TS2339 [ERROR]: Property 'push' does not exist on type 'readonly string[]'.
lines.push("c");
~~~~
at file:///programs/readonly-array.ts:3:7
TS2339 [ERROR]: Property 'pop' does not exist on type 'readonly string[]'.
lines.pop();
~~~
at file:///programs/readonly-array.ts:4:7
TS2339 [ERROR]: Property 'shift' does not exist on type 'readonly string[]'.
lines.shift();
~~~~~
at file:///programs/readonly-array.ts:5:7
TS2339 [ERROR]: Property 'unshift' does not exist on type 'readonly string[]'.
lines.unshift("z");
~~~~~~~
at file:///programs/readonly-array.ts:6:7
TS2551 [ERROR]: Property 'splice' does not exist on type 'readonly string[]'. Did you mean 'slice'?
lines.splice(0, 1);
~~~~~~
at file:///programs/readonly-array.ts:7:7
'slice' is declared here.
slice(start?: number, end?: number): T[];
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at asset:///lib.es5.d.ts:1222:5
TS2339 [ERROR]: Property 'sort' does not exist on type 'readonly string[]'.
lines.sort();
~~~~
at file:///programs/readonly-array.ts:8:7
TS2339 [ERROR]: Property 'reverse' does not exist on type 'readonly string[]'.
lines.reverse();
~~~~~~~
at file:///programs/readonly-array.ts:9:7
TS2339 [ERROR]: Property 'fill' does not exist on type 'readonly string[]'.
lines.fill("x");
~~~~
at file:///programs/readonly-array.ts:10:7
TS2339 [ERROR]: Property 'copyWithin' does not exist on type 'readonly string[]'.
lines.copyWithin(0, 1);
~~~~~~~~~~
at file:///programs/readonly-array.ts:11:7
TS2542 [ERROR]: Index signature in type 'readonly string[]' only permits reading.
lines[0] = "z";
~~~~~~~~
at file:///programs/readonly-array.ts:12:1
TS2540 [ERROR]: Cannot assign to 'length' because it is a read-only property.
lines.length = 0;
~~~~~~
at file:///programs/readonly-array.ts:13:7
Found 11 errors.
error: Type checking failed.
Eleven lines, eleven errors, and they are the eleven operations from the top of this entry. The methods are simply not there, which is why the message is "does not exist" rather than a complaint about mutation: readonly T[] is a different type with a smaller interface, and the index signature and length are read-only in it. The checker even makes this page's next joke on its own, offering "Did you mean 'slice'?" for splice. Delete the scratch file, and measure what a readonly array still can do:
Deno.test("readonly removes exactly those eleven", () => {
const lines: readonly string[] = ["b", "a"];
const kept = [
lines.toSorted(),
lines.toReversed(),
lines.toSpliced(0, 1),
lines.with(0, "x"),
lines.slice(),
lines.concat("c"),
lines.filter((line) => line !== "a"),
];
assertEquals(kept, [
["a", "b"],
["a", "b"],
["a"],
["x", "a"],
["b", "a"],
["b", "a", "c"],
["b"],
]);
assertEquals(lines, ["b", "a"]);
});
readonly removes exactly those eleven ... ok (70µs)
Every non-destructive operation is available, and that is the strongest practical argument for the ES2023 twins, worth stating plainly: they make readonly a type you can work inside rather than one you have to escape from. Before they existed, sorting a readonly array meant copying it first, so readonly cost a line every time you used it, and people stopped using it. The values and references page makes the complementary point, that readonly is intent rather than law: it vanishes at run time and a caller can cast it away. Both are true. It is not a guarantee about the array, and it is a guarantee about the code the checker sees, which is where you make your mistakes.
the twins forget a tuple's layout
Deno.test("the twins forget a tuple's layout", () => {
const pair: [string, number] = ["Ada", 36];
const changed = pair.with(0, "Grace");
assertEquals(changed, ["Grace", 36]);
assertEquals(pair.toReversed(), [36, "Ada"]);
// @ts-expect-error: Property 'toUpperCase' does not exist on type 'string | number'.
void changed[0].toUpperCase;
const restated: [string, number] = [
changed[0] as string,
changed[1] as number,
];
assertEquals(restated, ["Grace", 36]);
});
the twins forget a tuple's layout ... ok (34µs)
The values are right and the type is not. with and toReversed on a tuple both return (string | number)[]: a plain array of the union, with no fixed length and no idea which position holds what, which the pin records by failing to call a string method on element zero. That is a real cost when a tuple was the point, from the arrays page's case for them, and restating the layout needs casts, because the checker no longer knows that element zero is the string. Which is ugly enough to be a signal: if you are changing one position of a fixed layout, an object with named fields is usually the better shape, and spreading it keeps its type.
slice copies a run, splice replaces one
Deno.test("slice copies a run, splice replaces one", () => {
const letters = ["a", "b", "c", "d"];
assertEquals(letters.slice(1, 3), ["b", "c"]);
assertEquals(letters.slice(-2), ["c", "d"]);
assertEquals(letters, ["a", "b", "c", "d"]);
assertEquals(letters.toSpliced(1, 2, "x", "y"), ["a", "x", "y", "d"]);
assertEquals(letters.toSpliced(-2), ["a", "b"]);
const cut = ["a", "b", "c", "d"];
assertEquals(cut.splice(2), ["c", "d"]);
assertEquals(cut, ["a", "b"]);
});
slice copies a run, splice replaces one ... ok (37µs)
Two methods, one letter apart, and the way to keep them straight: slice is the common one and the common word, taking a piece and leaving the original whole, while splice has one more letter and does more, taking a start, a count to remove, and any number of replacements, all at once. Both accept a negative start, and splice with no count removes everything from the start position on. The honest advice is that you rarely want splice: removing elements by condition is filter, inserting them is a spread, and both say what they mean at the call site. Keep toSpliced for the case where an index really is what you have.
with refuses an index that does not exist
Deno.test("with refuses an index that does not exist", () => {
assertEquals(["a", "b"].with(-1, "x"), ["a", "x"]);
assertThrows(() => ["a"].with(5, "x"), RangeError, "Invalid index : 5");
assertThrows(() => ["a"].with(-5, "x"), RangeError, "Invalid index : -5");
const stretched = ["a"];
stretched[5] = "x";
assertStrictEquals(stretched.length, 6);
assertEquals(Object.keys(stretched), ["0", "5"]);
});
with refuses an index that does not exist ... ok (298µs)
Here the newer method is stricter than the syntax it replaces, and in the direction you want. with treats a negative index as counting from the end, and throws for any index outside the array in either direction. Compare the bracket write underneath: assigning past the end extends the array and fills the gap with holes, silently, which is one of the four ways to make a sparse array that the arrays page lists. One operation tells you the index was wrong; the other quietly rearranges your data to accommodate it.
copies are shallow
Copy an array of objects with slice, change one element through the copy, and predict the original:
Deno.test("copies are shallow", () => {
const rows = [{ name: "Ada" }];
const shallow = rows.slice();
const deep = structuredClone(rows);
shallow[0].name = "Grace";
assertStrictEquals(rows[0].name, "Ada");
assertStrictEquals(deep[0].name, "Ada");
});
Check programs/mutating-arrays.test.ts
running 8 tests from ./programs/mutating-arrays.test.ts
...
copies are shallow ... FAILED (7ms)
ERRORS
copies are shallow => ./programs/mutating-arrays.test.ts:123:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- Grace
+ Ada
FAILURES
copies are shallow => ./programs/mutating-arrays.test.ts:123:6
FAILED | 7 passed | 1 failed (9ms)
error: Test failed
The write reached the original. A copy of an array of objects gives you a new array holding the same objects, so changing one through the copy changes it through the original. That is not a quirk of arrays; it is what copying means for references, the values and references page's subject, and it holds for every ordinary copy, whether spelled slice(), Array.from, spread, or .values().toArray(). structuredClone is the one that is different, which the untouched deep proves. Correct the prediction to "Grace":
copies are shallow ... ok (108µs)
Which means the non-destructive methods protect the array and not its contents: a toSorted result is safe to reorder, and its elements are still shared.
Object.freeze is the run-time half
Deno.test("Object.freeze is the run-time half", () => {
const frozen = Object.freeze(["b", "a"]);
const writable = frozen as string[];
assertThrows(
() => writable.push("c"),
TypeError,
"Cannot add property 2, object is not extensible",
);
assertThrows(
() => {
writable[0] = "z";
},
TypeError,
"Cannot assign to read only property '0' of object '[object Array]'",
);
assertThrows(
() => writable.sort(),
TypeError,
"Cannot assign to read only property '0' of object '[object Array]'",
);
assertEquals(frozen.toSorted(), ["a", "b"]);
assertEquals(frozen, ["b", "a"]);
});
Object.freeze is the run-time half ... ok (84µs)
readonly is checked and Object.freeze is enforced, and the two are worth using together for an array you publish; the cast at the top is the one a caller could always make. Read the three failures. Appending fails because a frozen object is not extensible. Writing an element fails because every element became a read-only property. And sort fails for the same reason as the element write, because sorting works by assigning elements, which is a neat demonstration that these methods have no special powers: they are ordinary writes underneath. Then the last two lines: toSorted works on a frozen array, because it never writes to it, so the same property that makes the twins good for readonly makes them the only option for frozen data. These throw only because module code is strict; in sloppy code a write to a frozen object fails silently, worth knowing if you ever meet one in a script.
non-destructive is not free
Deno.test("non-destructive is not free", () => {
function totalByPush(values: number[]): number[] {
const running: number[] = [];
let sum = 0;
for (const value of values) {
sum += value;
running.push(sum);
}
return running;
}
function totalBySpread(values: number[]): number[] {
return values.reduce<number[]>(
(running, value) => [
...running,
(running.at(-1) ?? 0) + value,
],
[],
);
}
assertEquals(totalByPush([1, 2, 3]), [1, 3, 6]);
assertEquals(totalBySpread([1, 2, 3]), [1, 3, 6]);
});
non-destructive is not free ... ok (58µs)
Both produce [1, 3, 6], and only one of them is reasonable. The first does one write per element. The second copies the whole accumulated array on every element, so the work grows with the square of the length: ten elements cost about fifty copies, a thousand elements cost about half a million. No measurement is needed to see it, and it is worth naming because the spread version looks like the more principled one. The resolution is the one the closures page reaches about local mutation: push into an array that no other code can see is not mutation anyone can observe, so it costs nothing in clarity. The rule is about reach, not about the operation: mutate what you made, never what you were given.
In practice
- Prefer
toSorted,toReversed,toSpliced, andwithunless changing the original array is the point. - Use
pushfreely while building a local array that is not yet shared. - Do not mutate a parameter unless the function's name promises mutation. Accept
readonly T[]when the function only reads. - Remember that the return value of
sort,reverse,fill, orcopyWithinis the original array, not a copy.
Related
- Transforming arrays covers the callback methods.
- Arrays covers what an array is underneath, including why an index write can create holes.