JavaScript Array Initialization Pitfalls: A Complete Guide to Avoiding Common Bugs

Master JavaScript array initialization to avoid mysterious bugs in algorithms and dynamic programming. Learn the difference between mutable references and immutable primitives.


Have you ever been stumped by a bug where changing one element in a 2D array mysteriously changes an entire column? This often happens when tackling dynamic programming (DP) or matrix problems in JavaScript. As I discovered while working on the "Unique Paths II" LeetCode problem, a deep understanding of JavaScript's core concepts of mutability, assignment, and array initialization is not just academic—it's essential for writing correct and robust code.

This guide will walk you through that same chain of exploration:

  1. The Fundamentals: Mutable vs. Immutable, Assignment vs. Mutation, and let vs. const.
  2. The Pitfall: The subtle but dangerous behavior of Array.fill().
  3. The Solution: The correct patterns for safe array initialization.
  4. The Application: A real-world case study with a DP problem.

Part 1: The Fundamentals - Why These Bugs Happen

Before we look at the Array.fill() pitfall, we need to understand three core JavaScript concepts.

Mutable vs. Immutable Values

In JavaScript, data types are divided into two categories:

  • Immutable (Primitives): Their value cannot be changed after creation. If you want to "change" them, you create a new value.
    • string, number, boolean, null, undefined, symbol, bigint
  • Mutable (Objects): Their contents can be changed after creation.
    • object, array, function, Set, Map

This is the root of our problem: arrays are mutable.

Assignment vs. Mutation

This is the most critical distinction:

  • Assignment (=): Makes a variable point to a new value or reference.
    let arr = [1, 2, 3];
    arr = [4, 5, 6]; // ASSIGNMENT: arr now points to a completely new array.
  • Mutation: Changes the internal state of the object or array that a variable points to.
    let arr = [1, 2, 3];
    arr[0] = 99; // MUTATION: The original array is modified. It's now [99, 2, 3].

const Does Not Mean Immutable

A common misconception is that const makes a value immutable. It does not. const only creates an immutable binding. It prevents you from re-assigning the variable, but it does nothing to prevent mutation of the value it points to.

const arr = [1, 2, 3];
 
// This is a MUTATION, and it is perfectly fine.
arr[0] = 99;
console.log(arr); // [99, 2, 3]
 
// This is an ASSIGNMENT, and it will throw a TypeError.
// arr = [4, 5, 6]; // TypeError: Assignment to constant variable.

Part 2: The Pitfall - Array.fill() with Mutable Types

Now we can understand the main pitfall. When you use Array.fill() with a mutable type like an array or object, it fills every slot with a reference to the exact same instance.

// All rows share the SAME inner array reference
const badGrid = Array(3).fill(Array(3).fill(0));
 
// Let's mutate what we think is just the first row
badGrid[0][0] = 1;
 
console.log(badGrid);
// [[1, 0, 0],
//  [1, 0, 0],  <-- Wrong!
//  [1, 0, 0]] <-- Wrong!

Because every row points to the same underlying array, a change to one is a change to all. The same applies to objects:

const wrong = Array(3).fill({ a: 0 });
wrong[0].a = 42;
console.log(wrong);
// [{a: 42}, {a: 42}, {a: 42}] <-- all changed!

Part 3: The Solution - Correct Initialization Patterns

To correctly initialize a 2D array, you must create a fresh, new inner array for each row. The best way to do this is with a factory function inside Array.from or .map().

// ✅ Using Array.from
const grid1 = Array.from({ length: 3 }, () => Array(3).fill(0));
 
// ✅ Using .fill().map()
const grid2 = Array(3)
  .fill(0)
  .map(() => Array(3).fill(0));
 
grid1[0][0] = 1;
console.log(grid1);
// [[1, 0, 0],
//  [0, 0, 0],
//  [0, 0, 0]] <-- Correct!

Quick Checklist

  • 2D arrays / objects: Don’t fill(inner) with a pre-built object/array.
  • Use a factory callback: Array.from({ length }, () => fresh()).
  • 1D arrays with primitives: Safe to use .fill.
  • When deriving new rows: copy, don’t alias.
  • Consider typed arrays for numeric-heavy DP.

Part 4: Practical Applications

The importance of correct array initialization becomes crystal clear when tackling matrix-based algorithm problems.

Case Study 1: LeetCode "Unique Paths II"

In this problem, we need a DP table to store the number of paths to each cell. A common mistake is to initialize it incorrectly.

The Goal: Create an m x n grid initialized to 0.

The Wrong Way:

// This will cause bugs when we update the DP table.
const dp = Array(m).fill(Array(n).fill(0));

The Right Way:

const dp = Array.from({ length: m }, () => Array(n).fill(0));
// or
const dp = Array(m)
  .fill(0)
  .map(() => Array(n).fill(0));

With the dp table correctly initialized, the rest of the algorithm can safely mutate specific cells like dp[i][j] without causing side effects.

Case Study 2: LeetCode "59. Spiral Matrix II"

Another classic problem is generating an n x n matrix filled with numbers from 1 to n^2 in a spiral order.

The Goal: Create an n x n matrix and fill it spirally.

Once again, the very first step is to create the container for our matrix. A mistake here would be fatal to the algorithm.

The Right Way (and the only way that works):

// We need a grid of independent rows to write to.
const matrix = Array.from({ length: n }, () => Array(n).fill(0));

With the matrix correctly initialized, we can apply an elegant algorithm that uses four pointers to trace the boundaries of the spiral. Here is an optimized solution:

Optimized Solution:

/**
 * @param {number} n
 * @return {number[][]}
 */
var generateMatrix = function (n) {
  const res = Array.from({ length: n }, () => Array(n).fill(0));
  let colStart = 0,
    rowStart = 0;
  let ring = Math.floor(n / 2);
  let center = Math.floor(n / 2);
  let offset = 1;
  let num = 1;
  while (ring--) {
    let i = rowStart,
      j = colStart;
    // top: left to right (stop before the last column)
    for (; j < n - offset; j++) res[i][j] = num++;
    // right: top to bottom (stop before the last row)
    for (; i < n - offset; i++) res[i][j] = num++;
    // bottom: right to left (stop before the first column)
    for (; j > colStart; j--) res[i][j] = num++;
    // left: bottom to top (stop before the first row)
    for (; i > rowStart; i--) res[i][j] = num++;
 
    // Move the boundaries inward
    rowStart++;
    colStart++;
    offset++;
  }
 
  if (n % 2 === 1) res[center][center] = num;
  return res;
};

This approach is robust and avoids the need for special-casing for odd-sized matrices, demonstrating how a clean algorithm builds upon a correctly initialized data structure.

Part 5: Test Your Knowledge

Let's check your understanding with a few questions.

Q1: Does const arr = [1, 2, 3]; arr[0] = 99; work? Why? A: Yes. const prevents re-assignment of the arr variable, but the array itself is mutable, so its contents can be changed.

Q2: What is the key difference between Array(3).fill(Array(3).fill(0)) and Array(3).fill().map(() => Array(3).fill(0))? A: The first fills all rows with a reference to the same array. The second uses .map to create a new, independent array for each row.

Q3: What’s the difference between assignment and mutation in JavaScript? A: Assignment (=) changes which value or reference a variable points to. Mutation changes the internal contents of a referenced object or array.

Conclusion

Understanding the distinction between mutable and immutable types, and between assignment and mutation, is fundamental to writing correct JavaScript. The Array.fill() pitfall is a classic example of how these concepts have practical consequences in everyday coding, especially in algorithms. By internalizing these rules and using the correct initialization patterns, you’ll avoid subtle bugs and write cleaner, safer, and more professional code.