Working with Arrays in JavaScript

Working with Arrays in JavaScript

Arrays were one of the first data structures I learned in JavaScript, and yet I kept discovering new depth in them years into my career — from how they’re actually stored in memory to subtle bugs around sparse arrays and array-like objects. In this article, I want to cover arrays from the ground up: creating them, accessing and modifying elements, common patterns, and what’s actually happening internally when I work with them.

Creating Arrays

const fruits = ["apple", "banana", "cherry"]; // array literal — what I use almost always
const numbers = new Array(1, 2, 3);            // constructor form, rarely needed
const empty = new Array(5);                    // creates a sparse array with length 5, no actual elements!

console.log(empty.length); // 5
console.log(empty);        // [ <5 empty items> ]

I almost always use array literals ([]) instead of the Array constructor, because new Array(5) behaves unexpectedly — it creates a sparse array of a given length, not an array containing the number 5, which trips people up constantly.

const filled = Array.from({ length: 5 }, (_, i) => i * 2);
console.log(filled); // [0, 2, 4, 6, 8]

const filled2 = Array(5).fill(0);
console.log(filled2); // [0, 0, 0, 0, 0]

Array.from() with a mapping function is my preferred way to generate a sequence of computed values.

Accessing and Modifying Elements

const colors = ["red", "green", "blue"];

console.log(colors[0]);          // red
console.log(colors[colors.length - 1]); // blue — last element
console.log(colors.at(-1));      // blue — modern, cleaner way to access from the end

colors[1] = "yellow";
console.log(colors); // ["red", "yellow", "blue"]

.at(-1) is a relatively recent addition I use constantly now instead of arr[arr.length - 1] — it’s simply more readable, and it also works cleanly with negative indices for any position from the end.

Adding and Removing Elements

const stack = [1, 2, 3];

stack.push(4);       // add to end -> [1, 2, 3, 4]
stack.pop();          // remove from end -> [1, 2, 3]
stack.unshift(0);     // add to start -> [0, 1, 2, 3]
stack.shift();        // remove from start -> [1, 2, 3]

console.log(stack); // [1, 2, 3]
MethodAdds/RemovesPositionMutates?
push()AddsEndYes
pop()RemovesEndYes
unshift()AddsStartYes
shift()RemovesStartYes
splice()Adds/RemovesAnywhereYes

push/pop operate at the end of the array and are O(1) — fast, regardless of array size. unshift/shift operate at the start and are O(n) — every existing element has to be re-indexed, which can matter for performance on very large arrays.

splice() for Precise Insertion and Removal

const items = ["a", "b", "c", "d"];

items.splice(1, 2);              // remove 2 items starting at index 1
console.log(items);              // ["a", "d"]

items.splice(1, 0, "x", "y");    // insert without removing
console.log(items);              // ["a", "x", "y", "d"]

const removed = items.splice(0, 1, "z"); // replace one item, capture what was removed
console.log(items, removed);     // ["z", "x", "y", "d"], ["a"]

I use splice() whenever I need to insert, remove, or replace elements at a specific index — it’s the most versatile (and most commonly misused, due to its mutating nature) array method.

Searching Arrays

const nums = [5, 12, 8, 130, 44];

console.log(nums.indexOf(8));           // 2
console.log(nums.indexOf(999));         // -1 — not found
console.log(nums.includes(130));        // true
console.log(nums.find((n) => n > 100)); // 130
console.log(nums.findLast((n) => n < 50)); // 44 — searches from the end

Iterating Over Arrays

const letters = ["a", "b", "c"];

for (let i = 0; i < letters.length; i++) {
  console.log(i, letters[i]);
}

for (const letter of letters) {
  console.log(letter);
}

for (const [index, letter] of letters.entries()) {
  console.log(index, letter);
}

letters.forEach((letter, index) => console.log(index, letter));

I use for...of for simple iteration where I don’t need the index, .entries() when I need both index and value together in a for...of, and forEach() when I want a method-chaining style with a callback.

Multi-dimensional Arrays

const grid = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9],
];

console.log(grid[1][2]); // 6

for (const row of grid) {
  console.log(row.join(" "));
}
// 1 2 3
// 4 5 6
// 7 8 9

Array-Like Objects vs. Real Arrays

This distinction confused me for a long time. Things like arguments, NodeList (from querySelectorAll), and HTMLCollection (from getElementsByClassName) look like arrays — they have a .length and indexed elements — but they don’t have array methods like .map() or .filter() by default.

function example() {
  console.log(arguments.length); // works
  // arguments.map(x => x * 2); // TypeError: arguments.map is not a function
}

const realArray = Array.from(arguments); // convert to a real array first
const nodeList = document.querySelectorAll("div");
const realArray2 = [...nodeList]; // spread also converts array-likes (that are iterable) into real arrays

Array.from() works on any array-like or iterable object; spread (...) only works on iterables specifically — NodeList happens to be iterable, but not every array-like object is, so Array.from() is the more universally reliable conversion tool.

Sparse Arrays

const sparse = [1, , 3]; // note the empty slot at index 1
console.log(sparse.length); // 3
console.log(sparse[1]);     // undefined

sparse.forEach((item) => console.log(item)); // skips the empty slot entirely! Only logs 1 and 3
console.log(sparse.map((x) => x * 2));        // [2, <1 empty item>, 6] — also skips it

I avoid creating sparse arrays intentionally, since methods like forEach, map, and filter all silently skip empty slots, which can hide bugs if I’m not careful.

Typed Arrays (Brief Overview)

For performance-sensitive numeric data (like working with binary data, WebGL, or audio buffers), JavaScript provides typed arrays, which store a fixed-type sequence of numbers far more efficiently than regular arrays.

const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
int32View[0] = 42;
console.log(int32View[0]); // 42
console.log(int32View.length); // 4 (16 bytes / 4 bytes per Int32)

I don’t use typed arrays in everyday app code, but they’re essential for anything involving binary data or heavy numeric computation, like image processing or WebAssembly interop.

Internal Working: How Arrays Are Actually Stored

This surprised me when I first learned it: JavaScript arrays are not true fixed-type, contiguous-memory arrays the way they are in languages like C. Under the hood, engines like V8 use different internal representations depending on how the array is used:

  • Packed SMI (small integer) arrays: fastest, used when all elements are small integers.
  • Packed double arrays: used when elements are floating-point numbers.
  • Packed/holey element arrays: used for mixed types or objects.
  • Dictionary mode: the slowest representation, used when an array becomes sparse or has too many non-index properties attached.

This is why performance-sensitive code benefits from keeping arrays homogeneous (same type throughout) and dense (no holes) — V8 can use faster internal representations, whereas mixing types or creating sparse arrays forces a fallback to slower, more general storage.

const fast = [1, 2, 3, 4, 5];       // likely a packed SMI array internally
const slower = [1, "two", 3, {}];   // mixed types force a more general representation

I don’t obsess over this in everyday code, but for hot loops processing large datasets, keeping arrays homogeneous is a genuinely useful optimization to know about.

Practical, Real-World Patterns

Chunking an Array into Smaller Groups

function chunk(array, size) {
  const result = [];
  for (let i = 0; i < array.length; i += size) {
    result.push(array.slice(i, i + size));
  }
  return result;
}

console.log(chunk([1, 2, 3, 4, 5, 6, 7], 3));
// [[1, 2, 3], [4, 5, 6], [7]]

Removing Falsy Values

const mixed = [0, "hello", "", null, 42, undefined, false, "world"];
const truthy = mixed.filter(Boolean);
console.log(truthy); // ["hello", 42, "world"]

Building a Simple Pagination Helper

function paginate(items, page, perPage) {
  const start = (page - 1) * perPage;
  return items.slice(start, start + perPage);
}

const allItems = Array.from({ length: 25 }, (_, i) => i + 1);
console.log(paginate(allItems, 2, 10)); // [11, 12, ..., 20]

Best Practices

  • I use array literals over the Array constructor to avoid the single-argument-length gotcha.
  • I use .at(-1) instead of arr[arr.length - 1] for cleaner end-of-array access.
  • I use Array.from() to reliably convert array-like or iterable objects into real arrays.
  • I avoid creating sparse arrays, since built-in iteration methods silently skip empty slots.
  • I keep arrays homogeneous in performance-critical code paths.

Common Mistakes to Avoid

  1. Using new Array(5) expecting an array containing 5, instead of a sparse array of length 5.
  2. Calling array methods directly on array-like objects like NodeList or arguments without converting them first (though modern NodeList does support forEach natively, it still lacks map/filter).
  3. Forgetting unshift/shift are O(n) operations, which can cause performance issues on very large arrays used as queues.
  4. Not realizing sparse arrays silently skip holes in forEach, map, and filter.

Debugging Tips

  • I use console.table(array) for arrays of objects to get an instantly readable, sortable table view.
  • I check Array.isArray(value) before calling array methods on a value I’m not 100% sure is an actual array.
  • For unexpected undefined results, I check whether I’ve accidentally created a sparse array with a stray comma or an out-of-bounds index assignment.

FAQs

Q: How do I check if a variable is actually an array? A: Array.isArray(value) — this is more reliable than typeof value === "object", since arrays report typeof "object" too.

Q: What’s the fastest way to remove duplicates from an array? A: [...new Set(array)] — clean, concise, and handles primitive duplicates efficiently.

Q: Why does [1, 2, 3].length = 1 truncate the array? A: length is a writable property directly tied to the array’s internal element count — setting it to a smaller value actually deletes the trailing elements.

Q: Are arrays passed by value or by reference in function calls? A: By reference (technically, the reference itself is passed by value) — modifying an array’s contents inside a function affects the original array outside it, unless you explicitly copy it first.

Summary and Key Takeaways

  • Prefer array literals over the Array constructor to avoid confusing edge cases.
  • push/pop are fast (O(1)); unshift/shift are slower (O(n)) since they re-index the whole array.
  • splice() is the versatile tool for inserting, removing, or replacing elements at specific positions.
  • Array-like objects (arguments, NodeList) need conversion via Array.from() or spread before using array methods.
  • Avoid sparse arrays — built-in iteration methods silently skip holes, which can hide bugs.
  • Engines optimize homogeneous, dense arrays far better than mixed-type or sparse ones.

Arrays look simple on the surface, but understanding their edge cases and internal representation has repeatedly helped me avoid subtle bugs and write more performant code when working with large or complex datasets.

References

Total
0
Shares

Leave a Reply

Previous Post
JavaScript Basics: Functions and Scope

JavaScript Basics: Functions and Scope

Next Post
Manipulating Objects in JavaScript

Manipulating Objects in JavaScript

Related Posts