Skip to content

JavaScript will soon be more functional (change-array-by-copy)

Posted on:April 15, 2023 at 01:36 AM in US/Central
6 min read

Sometimes you want to .sort, .splice, or .reverse an array, but unlike .map, .filter, .reduce, and others, those methods mutate (change) the original array. Also, .splice returns the removed elements, so it’s not nice to use them in a chain of methods. In Functional Programming, functions are supposed to be pure, meaning that they don’t have side-effects such as mutating the inputs/outer state. Making functions pure when possible can make your code easier to reason about, test, and debug, but when it’s inconvenient I like to mix programming styles. In this post, I’ll explain a proposal that adds new methods to the Array prototype that return a mutated copy of the array instead of mutating the array in place.

Table of Contents

Open Table of Contents

The Problem

As mentioned above, JavaScript’s array methods are inconsistent with mutations. Some methods mutate the array, others return a mutated copy of the array. .splice returns the removed elements instead of the mutated array, which prevents the chaining of array methods.

/* Independent calls example */
const arr = [3, 5, 4, 1, 2];
const arr2 = arr.sort((a, b) => a - b);
const arr3 = arr.filter(x => x > 2);
const arr4 = arr.reverse();

console.log(arr); // [5, 4, 3, 2, 1]
console.log(arr2); // [5, 4, 3, 2, 1]
console.log(arr3); // [3, 4, 5]
console.log(arr4); // [5, 4, 3, 2, 1]

Not even GitHub Copilot understands which methods mutate the array and which don’t.

This wacky behavior happens because arr, arr2, and arr4 are all referencing the same array. .sort mutates in-place, .filter is a new array based on arr (which was mutated by the .sort), and .reverse mutates in-place. This example is pretty simple, but imagine having a bug due to arrays being mutated when you expected them to be copied.

If I do the same operations but chained (separated into variables to be able to log), a different result is produced:

/* Chained calls example */
const arr = [3, 5, 4, 1, 2];
const arr2 = arr.sort((a, b) => a - b); // [1, 2, 3, 4, 5]
const arr3 = arr2.filter(x => x > 2); // [3, 4, 5]
const arr4 = arr3.reverse(); // [5, 4, 3]

console.log(arr); // [1, 2, 3, 4, 5]
console.log(arr2); // [1, 2, 3, 4, 5]
console.log(arr3); // [5, 4, 3]
console.log(arr4); // [5, 4, 3]

Since we’re chaining, it doesn’t really matter that arr3 is mutably reversed, however we still have the side-effect of arr being mutated by .sort.

The New Solution

There’s a Stage 4 proposal to the ECMAScript Standard (ECMAScript is the generic name of JavaScript) that adds new methods to the Array prototype and Typed Array prototype that return a mutated copy of the array for the methods described above, as well as a new method .with(index, value) that acts like arr[index] = value. The proposal will be implemented in the 2023 edition of ECMAScript, which has come out in June for the past several years.

Here are the (proposed*) TypeScript definitions for the new methods:

interface Array<T> {
  toSorted(compareFn?: (a: T, b: T) => number): T[];
  toSpliced(start: number, deleteCount?: number): T[];
  // Overload, with extra info in TSDoc format
  toSpliced(start: number, deleteCount: number, ...items: T[]): T[];
  toReversed(): T[];
  with(index: number, value: T): T[];
}

There was some debate over whether .toSpliced should be allowed to introduce something of a different type into the array:

interface Array<T> {
  toSpliced<F>(start: number, deleteCount: number, ...items: F[]): (T | F)[];
}

Since .splice doesn’t allow that, as well as every other method in the Array prototype and modification via bracket notation (arr[index] = value), it was decided that .toSpliced should not allow it either.

When we use these methods in the same examples as above, we get the following results:

/* Independent calls example */
const arr = [3, 5, 4, 1, 2];
const arr2 = arr.toSorted((a, b) => a - b); // [1, 2, 3, 4, 5]
const arr3 = arr.filter(x => x > 2); // [3, 5, 4]
const arr4 = arr.toReversed(); // [2, 1, 4, 5, 3]

console.log(arr); // [3, 5, 4, 1, 2]
console.log(arr2); // [1, 2, 3, 4, 5]
console.log(arr3); // [3, 5, 4]
console.log(arr4); // [2, 1, 4, 5, 3]
/* Chained calls example */
const arr = [3, 5, 4, 1, 2];
const arr2 = arr.toSorted((a, b) => a - b); // [1, 2, 3, 4, 5]
const arr3 = arr2.filter(x => x > 2); // [3, 4, 5]
const arr4 = arr3.toReversed(); // [5, 4, 3]

console.log(arr); // [3, 5, 4, 1, 2]
console.log(arr2); // [1, 2, 3, 4, 5]
console.log(arr3); // [3, 4, 5]
console.log(arr4); // [5, 4, 3]

As you can see, every array is now independent of the others, and the original array is not mutated.

Drawbacks

The downside of this proposal is that each time we chain a method, a whole new array is created. This can be a performance issue if you’re doing a lot of chaining, but it’s not a big deal if you’re only chaining a few methods and the array is small. I’m sure that engines could optimize for chained immutable calls, but I don’t know the precedent.

If performance becomes an issue, you can always perform a single copy beforehand and use mutable methods, and for methods that don’t have a mutable equivalent, you can implement them with a for loop.

As with the other Array methods, the new arrays are created immediately. If you’re interested in lazy evaluation, look out for the Stage 3 proposal Iterator Helpers or use a library such as itertools or iter-tools.

Alternatives

These new methods don’t need to exist, but they can make code more readable and less error-prone. It’s worth noting some alternatives that you can use:

Browser/Engine support

(copied from the proposal, with a small change)

These methods are ready to be used today, and polyfills exist for older browsers. You probably already have a system for transpiling your code into older versions of JavaScript, and that system will probably work with these methods.

Conclusion

This proposal is pretty cool, bringing .sort, .splice, and .reverse to the same level as the other methods on Arrays, without breaking backwards compatibility. It’s also a great way to make your code more readable and less error-prone.

If you don’t want to use them, don’t. If you need to write more performant code, do so.

They’re ready for use today, so don’t be afraid of them because they’re new.

If you have any questions or see a typo, feel free to open an issue or pull request, or contact me on Twitter.

Primeagen, if you’re reading this, you should be afraid because of closures 😱 and copies 🤯.