Array Non-Mutating Methods: Operations That Keep Data Intact
Imagine you work in an archive and need to extract information from historical documents. You wouldn't write notes or make modifications directly on the original files—those are precious originals. Instead, you would photocopy the sections you need and work on the copies, keeping the originals intact. JavaScript's non-mutating methods follow the same philosophy: they extract or derive information from the original array but never modify the original array itself. This approach makes data more reliable and code more predictable, and is a core concept of functional programming.
What are Non-Mutating Methods
Non-mutating methods are methods that do not modify the original array they are called on. After these methods execute, the original array remains unchanged, and they usually return a new array, string, or other value.
Let's first compare the differences between mutating and non-mutating methods:
let originalArray = [1, 2, 3];
// Mutating method: modifies original array
let mutated = originalArray;
mutated.push(4);
console.log(originalArray); // [1, 2, 3, 4] - original array modified
let numbers = [1, 2, 3];
// Non-mutating method: returns new array, original array unchanged
let concatenated = numbers.concat([4, 5]);
console.log(numbers); // [1, 2, 3] - original array intact
console.log(concatenated); // [1, 2, 3, 4, 5] - new arrayThe core advantage of non-mutating methods is predictability and security. When you call these methods, you can be confident that the original array won't be accidentally modified, which is particularly important when data is shared across multiple places or when you need to preserve historical state.
concat() - Merge Arrays
The concat() method is used to merge two or more arrays, returning a new array without modifying the original arrays.
Basic Usage
let fruits = ["apple", "banana"];
let vegetables = ["carrot", "broccoli"];
// Merge two arrays
let food = fruits.concat(vegetables);
console.log(food); // ["apple", "banana", "carrot", "broccoli"]
console.log(fruits); // ["apple", "banana"] - original array unchanged
console.log(vegetables); // ["carrot", "broccoli"] - original array unchanged
// Merge multiple arrays
let arr1 = [1, 2];
let arr2 = [3, 4];
let arr3 = [5, 6];
let combined = arr1.concat(arr2, arr3);
console.log(combined); // [1, 2, 3, 4, 5, 6]
// Add single element
let numbers = [1, 2, 3];
let withFour = numbers.concat(4);
console.log(withFour); // [1, 2, 3, 4]
// Mix arrays and single elements
let mixed = numbers.concat(4, [5, 6], 7);
console.log(mixed); // [1, 2, 3, 4, 5, 6, 7]Comparison with Spread Operator
Modern JavaScript provides a more concise syntax with the spread operator (...):
let arr1 = [1, 2];
let arr2 = [3, 4];
// Using concat
let result1 = arr1.concat(arr2);
// Using spread operator
let result2 = [...arr1, ...arr2];
console.log(result1); // [1, 2, 3, 4]
console.log(result2); // [1, 2, 3, 4]
// Spread operator is more flexible
let result3 = [...arr1, 99, ...arr2, 100];
console.log(result3); // [1, 2, 99, 3, 4, 100]Shallow Copy Characteristics
Note that concat() only performs shallow copying. If the array contains objects, the objects in the new array still reference the original objects:
let users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
];
let moreUsers = users.concat([{ name: "Charlie", age: 35 }]);
// Modifying objects in the new array affects the same objects in the original
moreUsers[0].age = 26;
console.log(users[0].age); // 26 - object in original array also modified
// But adding new elements doesn't affect the original
moreUsers.push({ name: "David", age: 40 });
console.log(users.length); // 2 - original array length unchangedslice() - Extract Array Segments
The slice() method extracts a segment of elements from an array and returns a new array. It does not modify the original array.
Basic Usage
Syntax: array.slice(start, end)
- start: Index to start extraction (inclusive)
- end: Index to end extraction (exclusive), optional
let numbers = [0, 1, 2, 3, 4, 5];
// Extract elements from index 2 to 4 (not including 4)
let sliced = numbers.slice(2, 4);
console.log(sliced); // [2, 3]
console.log(numbers); // [0, 1, 2, 3, 4, 5] - original array unchanged
// From index 2 to end
let fromTwo = numbers.slice(2);
console.log(fromTwo); // [2, 3, 4, 5]
// Extract last 3 elements
let lastThree = numbers.slice(-3);
console.log(lastThree); // [3, 4, 5]
// Copy entire array
let copy = numbers.slice();
console.log(copy); // [0, 1, 2, 3, 4, 5]Negative Indices
slice() supports negative indices, counting from the end of the array:
let fruits = ["apple", "banana", "orange", "grape", "mango"];
// From third-to-last to second-to-last (not including)
let lastTwo = fruits.slice(-3, -1);
console.log(lastTwo); // ["orange", "grape"]
// From index 1 to second-to-last (not including)
let middlePart = fruits.slice(1, -1);
console.log(middlePart); // ["banana", "orange", "grape"]Practical Application: Pagination
function paginate(array, pageSize, pageNumber) {
let start = (pageNumber - 1) * pageSize;
let end = start + pageSize;
return array.slice(start, end);
}
let items = [
"Item 1",
"Item 2",
"Item 3",
"Item 4",
"Item 5",
"Item 6",
"Item 7",
"Item 8",
"Item 9",
"Item 10",
];
console.log(paginate(items, 3, 1)); // ["Item 1", "Item 2", "Item 3"]
console.log(paginate(items, 3, 2)); // ["Item 4", "Item 5", "Item 6"]
console.log(paginate(items, 3, 3)); // ["Item 7", "Item 8", "Item 9"]
console.log(paginate(items, 3, 4)); // ["Item 10"]Comparison with splice()
slice() and splice() have similar names but completely different behaviors:
let arr1 = [1, 2, 3, 4, 5];
let arr2 = [1, 2, 3, 4, 5];
// slice: non-mutating, extracts segment
let sliced = arr1.slice(1, 4);
console.log(arr1); // [1, 2, 3, 4, 5] - unchanged
console.log(sliced); // [2, 3, 4]
// splice: mutating, deletes and inserts
let spliced = arr2.splice(1, 3, 99);
console.log(arr2); // [1, 99, 5] - modified
console.log(spliced); // [2, 3, 4] - returns deleted elementsjoin() - Convert to String
The join() method connects all elements of an array into a string, with elements separated by a specified separator.
let fruits = ["apple", "banana", "orange"];
// Use default separator (comma)
let str1 = fruits.join();
console.log(str1); // "apple,banana,orange"
// Use custom separator
let str2 = fruits.join(" - ");
console.log(str2); // "apple - banana - orange"
// Use empty string (no separator)
let letters = ["H", "e", "l", "l", "o"];
let word = letters.join("");
console.log(word); // "Hello"
// Use newline character
let lines = ["First line", "Second line", "Third line"];
let text = lines.join("\n");
console.log(text);
// First line
// Second line
// Third linePractical Application Scenarios
// Generate URL path
let pathSegments = ["api", "users", "123", "posts"];
let url = "/" + pathSegments.join("/");
console.log(url); // "/api/users/123/posts"
// Generate CSV data
function createCSV(rows) {
return rows.map((row) => row.join(",")).join("\n");
}
let data = [
["Name", "Age", "City"],
["Alice", "25", "New York"],
["Bob", "30", "London"],
["Charlie", "35", "Tokyo"],
];
console.log(createCSV(data));
// Name,Age,City
// Alice,25,New York
// Bob,30,London
// Charlie,35,Tokyo
// Format numbers
let phoneNumber = [555, 123, 4567];
let formatted = `(${phoneNumber[0]}) ${phoneNumber[1]}-${phoneNumber[2]}`;
console.log(formatted); // (555) 123-4567
// Or use join
let phone = [555, 123, 4567];
let formatted2 = `(${phone[0]}) ${phone.slice(1).join("-")}`;
console.log(formatted2); // (555) 123-4567toString() - Convert to String
The toString() method returns the string representation of an array, equivalent to join() without parameters:
let numbers = [1, 2, 3, 4, 5];
console.log(numbers.toString()); // "1,2,3,4,5"
console.log(numbers.join()); // "1,2,3,4,5" - same result
// Nested arrays are flattened
let nested = [1, [2, 3], [4, [5, 6]]];
console.log(nested.toString()); // "1,2,3,4,5,6"Usually, join() is more flexible because you can customize the separator. toString() is mainly used for automatic type conversion when a string representation is needed.
indexOf() and lastIndexOf() - Find Element Positions
These two methods are used to find the position of elements in an array.
indexOf() - Search from Beginning
indexOf() returns the index of the first occurrence of an element in an array, or -1 if not found.
let fruits = ["apple", "banana", "orange", "banana", "grape"];
// Find element
console.log(fruits.indexOf("banana")); // 1 - position of first occurrence
console.log(fruits.indexOf("grape")); // 4
console.log(fruits.indexOf("mango")); // -1 - not found
// Start searching from specified position
console.log(fruits.indexOf("banana", 2)); // 3 - start searching from index 2
// Check if element exists
if (fruits.indexOf("apple") !== -1) {
console.log("Found apple!");
}lastIndexOf() - Search from End
lastIndexOf() returns the index of the last occurrence of an element in an array:
let numbers = [1, 2, 3, 2, 1];
console.log(numbers.indexOf(2)); // 1 - first occurrence
console.log(numbers.lastIndexOf(2)); // 3 - last occurrence
console.log(numbers.indexOf(1)); // 0
console.log(numbers.lastIndexOf(1)); // 4
// Search forward from specified position
console.log(numbers.lastIndexOf(2, 2)); // 1Strict Equality Comparison
indexOf() and lastIndexOf() use strict equality (===) for comparison:
let items = [1, "1", 2, "2"];
console.log(items.indexOf(1)); // 0
console.log(items.indexOf("1")); // 1 - distinguishes type
let objects = [{ id: 1 }, { id: 2 }];
let obj = { id: 1 };
console.log(objects.indexOf(obj)); // -1 - different object references
console.log(objects.indexOf(objects[0])); // 0 - same referenceincludes() - Check if Element is Included
The includes() method determines whether an array contains a certain element and returns a boolean value. This is an ES2016 introduced method, more semantically clear than indexOf().
let fruits = ["apple", "banana", "orange"];
// Check if included
console.log(fruits.includes("banana")); // true
console.log(fruits.includes("grape")); // false
// Start checking from specified position
let numbers = [1, 2, 3, 4, 5];
console.log(numbers.includes(3, 2)); // true - start from index 2
console.log(numbers.includes(2, 2)); // false - no 2 after index 2includes() vs indexOf()
includes() is more suitable for simple existence checks, making code clearer:
let items = ["book", "pen", "notebook"];
// ❌ Using indexOf (less intuitive)
if (items.indexOf("pen") !== -1) {
console.log("Found pen");
}
// ✅ Using includes (clearer)
if (items.includes("pen")) {
console.log("Found pen");
}Another advantage of includes() is its ability to correctly handle NaN:
let values = [1, 2, NaN, 4];
console.log(values.indexOf(NaN)); // -1 - indexOf can't find NaN
console.log(values.includes(NaN)); // true - includes can find NaNPractical Application: Permission Checking
function checkPermissions(userRoles, requiredRole) {
return userRoles.includes(requiredRole);
}
let adminRoles = ["read", "write", "delete", "admin"];
let userRoles = ["read", "write"];
console.log(checkPermissions(adminRoles, "admin")); // true
console.log(checkPermissions(userRoles, "admin")); // false
console.log(checkPermissions(userRoles, "write")); // true
// Check multiple permissions
function hasAllPermissions(userRoles, requiredRoles) {
return requiredRoles.every((role) => userRoles.includes(role));
}
console.log(hasAllPermissions(adminRoles, ["read", "write"])); // true
console.log(hasAllPermissions(userRoles, ["read", "delete"])); // falseAdvantages of Non-Mutating Methods
1. Data Security
Non-mutating methods ensure original data isn't accidentally modified, which is particularly important when data is shared across multiple places:
let sharedConfig = {
features: ["dark-mode", "notifications", "analytics"],
};
function addFeature(features, newFeature) {
// ❌ Mutating method: modifies shared data
features.push(newFeature);
return features;
}
function addFeatureSafe(features, newFeature) {
// ✅ Non-mutating method: returns new array
return features.concat([newFeature]);
}
let userFeatures = addFeatureSafe(sharedConfig.features, "custom-theme");
console.log(sharedConfig.features); // ["dark-mode", "notifications", "analytics"] - unchanged
console.log(userFeatures); // ["dark-mode", "notifications", "analytics", "custom-theme"]2. Functional Programming Support
Non-mutating methods are the foundation of pure functions, supporting functional programming paradigms:
// Pure function: no side effects, same input produces same output
function getTop3(scores) {
return scores
.slice() // Copy array
.sort((a, b) => b - a) // Sort
.slice(0, 3); // Take first 3
}
let scores = [85, 92, 78, 95, 88, 90];
let top3 = getTop3(scores);
console.log(scores); // [85, 92, 78, 95, 88, 90] - original array unchanged
console.log(top3); // [95, 92, 90]
// Can call multiple times, same result each time
console.log(getTop3(scores)); // [95, 92, 90]
console.log(getTop3(scores)); // [95, 92, 90]3. Easy Testing and Debugging
Pure functions are easier to test because you don't need to consider external state:
function filterActive(users) {
return users.filter((user) => user.active);
}
// Testing is very simple
let testUsers = [
{ name: "Alice", active: true },
{ name: "Bob", active: false },
{ name: "Charlie", active: true },
];
let result = filterActive(testUsers);
console.log(result.length); // 2
console.log(testUsers.length); // 3 - original data unchanged, doesn't affect other tests4. Support Time Travel and Undo Functionality
Immutable data makes implementing history and undo functionality simple:
class History {
constructor(initialState) {
this.states = [initialState];
this.currentIndex = 0;
}
push(newState) {
// Remove states after current position
this.states = this.states.slice(0, this.currentIndex + 1);
// Add new state
this.states = this.states.concat([newState]);
this.currentIndex++;
}
undo() {
if (this.currentIndex > 0) {
this.currentIndex--;
}
return this.states[this.currentIndex];
}
redo() {
if (this.currentIndex < this.states.length - 1) {
this.currentIndex++;
}
return this.states[this.currentIndex];
}
current() {
return this.states[this.currentIndex];
}
}
let history = new History({ text: "" });
history.push({ text: "Hello" });
history.push({ text: "Hello World" });
history.push({ text: "Hello World!" });
console.log(history.current()); // { text: "Hello World!" }
console.log(history.undo()); // { text: "Hello World" }
console.log(history.undo()); // { text: "Hello" }
console.log(history.redo()); // { text: "Hello World" }Performance Considerations
Although non-mutating methods are safer, they usually need to create new arrays, which might affect performance when processing large arrays:
let largeArray = new Array(100000).fill(0);
// Mutating method: fast, in-place modification
console.time("mutating");
largeArray.push(1);
console.timeEnd("mutating"); // Very fast
// Non-mutating method: slower, needs to copy
let anotherLargeArray = new Array(100000).fill(0);
console.time("non-mutating");
let newArray = anotherLargeArray.concat([1]);
console.timeEnd("non-mutating"); // Relatively slowerIn actual development, you should weigh the trade-offs based on the scenario:
- Prioritize non-mutating methods unless there's a clear performance bottleneck
- For frequent operations on large arrays, consider using mutating methods
- You can use specialized immutable data structure libraries (like Immutable.js) to balance performance and immutability
Summary
Non-mutating methods are an important part of JavaScript array operations. They return new values without modifying the original array:
- concat() - Merge arrays, return new array
- slice() - Extract array segments, return new array
- join() - Convert array to string
- toString() - Convert to string representation
- indexOf() / lastIndexOf() - Find element positions
- includes() - Check if element is included
Advantages of non-mutating methods:
- Ensure data security, avoid accidental modifications
- Support functional programming and pure functions
- Easy to test and debug
- Facilitate implementing history and undo functionality
In the next article, we'll explore array iteration methods, including map(), filter(), reduce(), and other powerful functional operations, which are also non-mutating methods that enable declarative data processing.