Skip to content

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:

javascript
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 array

The 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

javascript
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 (...):

javascript
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:

javascript
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 unchanged

slice() - 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
javascript
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:

javascript
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

javascript
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:

javascript
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 elements

join() - Convert to String

The join() method connects all elements of an array into a string, with elements separated by a specified separator.

javascript
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 line

Practical Application Scenarios

javascript
// 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-4567

toString() - Convert to String

The toString() method returns the string representation of an array, equivalent to join() without parameters:

javascript
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.

javascript
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:

javascript
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)); // 1

Strict Equality Comparison

indexOf() and lastIndexOf() use strict equality (===) for comparison:

javascript
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 reference

includes() - 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().

javascript
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 2

includes() vs indexOf()

includes() is more suitable for simple existence checks, making code clearer:

javascript
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:

javascript
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 NaN

Practical Application: Permission Checking

javascript
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"])); // false

Advantages 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:

javascript
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:

javascript
// 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:

javascript
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 tests

4. Support Time Travel and Undo Functionality

Immutable data makes implementing history and undo functionality simple:

javascript
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:

javascript
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 slower

In 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.