Lesson 6 of 30

JavaScript Arrays

An Ordered List of Values

An array is an ordered list of values stored under one name. Where an object labels each value with a key, an array numbers them, starting from zero. That numbering is what makes arrays the right choice whenever you have many things of the same kind — twenty student names, a cart's worth of items, the rows returned by a query — and the wrong choice for one thing with several different attributes, which is an object's job.

You create an array with square brackets, and the values inside are called elements. You reach one by its index — its position, counting from zero. The first element sits at index 0, so the last element of a five-element array sits at index 4. Counting from zero feels arbitrary for about a week and then becomes invisible. The consequence you will use constantly is the expression arr[arr.length - 1] for the last element, or the newer and much more readable arr.at(-1).

An array can hold any mixture of types, including other arrays and objects. In practice you should keep the contents of a single array uniform — all numbers, or all student objects — because then every piece of code that touches it only has to handle one shape. The most common real structure by far is an array of objects, which is exactly how a list of records arrives from an API or a database.

Arrays are objects underneath, which is why typeof [1, 2] reports 'object' rather than anything array-shaped. To test whether a value is an array, use Array.isArray(value). That came up in Lesson 2, and it matters in real code whenever a function has to cope with both a single item and a list of them.

Example
// Create with square brackets
const subjects = ['Maths', 'Physics', 'Chemistry'];
const marks = [82, 74, 79];

// Index from zero
console.log(subjects[0]);                    // 'Maths'
console.log(subjects[2]);                    // 'Chemistry'
console.log(subjects[subjects.length - 1]);  // 'Chemistry' - the last one
console.log(subjects.at(-1));                // 'Chemistry' - same thing, clearer

// The shape you will meet most often: an array of objects
const students = [
  { name: 'Ananya', marks: 82 },
  { name: 'Rahul', marks: 68 }
];
console.log(students[1].name);        // 'Rahul'

// Arrays are objects underneath
console.log(typeof subjects);         // 'object'
console.log(Array.isArray(subjects)); // true

length, Out-of-Range Access and Holes

length is not a count that JavaScript keeps for you as a courtesy — it is a live property that always equals one more than the highest index in use. It updates by itself as you add and remove elements, and, unusually for a built-in property, you can write to it. Setting arr.length = 0 empties the array in place, and setting it to a smaller number truncates it.

Reading an index that does not exist returns undefined instead of throwing. That is the array version of the missing-property behaviour from the previous lesson, and it produces the same class of quiet bug: marks[10] on a three-element array gives undefined, and undefined + 5 is NaN, which then travels silently through every calculation after it. The classic way to trigger this is an off-by-one loop that runs while i <= arr.length and so reads one element past the end.

Writing to an index beyond the end does not throw either. It grows the array and leaves holes in between. An array with holes is called sparse, and it behaves inconsistently: length counts the holes, some methods skip them, and others treat them as undefined. You almost never want a sparse array. If you are adding to the end, use push, which cannot create a hole no matter what.

To create an array of a known size that is filled with real values, use Array(5).fill(0), or Array.from({ length: 5 }, (_, i) => i + 1) when the value depends on the position. Both give you a dense array with nothing missing. The underscore in that callback is a convention, not syntax — it is just a parameter name that says "I am not using this one".

Example
const marks = [82, 74, 79];

console.log(marks.length);     // 3
console.log(marks[3]);         // undefined - not an error
console.log(marks[3] + 5);     // NaN       - and now the problem spreads

// length is writable
marks.length = 2;
console.log(marks);            // [82, 74]

// Writing past the end grows the array and leaves holes
const sparse = [1, 2];
sparse[5] = 6;
console.log(sparse.length);    // 6 - indexes 2, 3 and 4 are holes
console.log(sparse[3]);        // undefined

// Build a dense array instead
console.log(Array(5).fill(0));                           // [0, 0, 0, 0, 0]
console.log(Array.from({ length: 5 }, (_, i) => i + 1)); // [1, 2, 3, 4, 5]
Notes
  • arr.length = 0 empties the array that everyone else is holding a reference to as well. Writing arr = [] instead only points your own name at a fresh empty array and leaves every other holder looking at the old, still-full one.

Adding and Removing at Either End

Four methods handle the ends of an array, and all four modify the array in place rather than returning a new one. push adds to the end. pop removes the last element. unshift adds to the front. shift removes the first element.

Pay attention to what each one hands back, because mixing that up is a frequent error. push and unshift return the new length, not the array — so const list = cart.push(item) leaves you holding a number and wondering where your array went. pop and shift return the element they removed, and both return undefined on an empty array rather than throwing.

There is a performance difference worth knowing even this early. push and pop work at the end and are cheap. shift and unshift work at the front, which means every remaining element has to move one position, so on a large array they are measurably slower. For a few dozen items this is irrelevant. For tens of thousands inside a loop it is the difference between instant and sluggish.

These four are also how you build the two structures from your data-structures syllabus without any extra code. push with pop gives a stack — last in, first out, which is exactly how undo history and the browser back button behave. push with shift gives a queue — first in, first out, like a print queue or a list of pending requests.

Example
const cart = ['Notebook'];

console.log(cart.push('Pen'));        // 2  <- the new length, not the array
console.log(cart);                    // ['Notebook', 'Pen']

console.log(cart.pop());              // 'Pen'  <- the removed element
console.log(cart);                    // ['Notebook']

console.log(cart.unshift('Eraser'));  // 2
console.log(cart);                    // ['Eraser', 'Notebook']

console.log(cart.shift());            // 'Eraser'
console.log(cart);                    // ['Notebook']

// Empty array: undefined, not an error
console.log([].pop());                // undefined

// A stack: push + pop
const history = [];
history.push('/home');
history.push('/courses');
console.log(history.pop());           // '/courses' - last in, first out

// A queue: push + shift
const jobs = [];
jobs.push('job-1');
jobs.push('job-2');
console.log(jobs.shift());            // 'job-1' - first in, first out
Notes
  • All four of these change the original array, so they work perfectly well on an array declared with const. const stops you pointing the name at a different array; it does not stop you changing the contents of the one it already points at.

slice and splice — the Two That Get Confused

These two have similar names, do different jobs, and treat the original array in opposite ways. Getting them straight once will save you a lot of debugging later.

slice(start, end) copies a section out and returns it as a new array, leaving the original untouched. The end index is exclusive, so slice(1, 3) gives you elements 1 and 2 — the same convention as almost every other range in the language. Omit both arguments and slice() returns a shallow copy of the whole array, which is an idiomatic way to copy one. Negative numbers count back from the end, so slice(-2) hands you the last two elements.

splice(start, deleteCount, ...itemsToAdd) changes the array in place. It removes deleteCount elements beginning at start, optionally inserts new ones at that spot, and returns an array containing whatever it removed. It is the general-purpose tool for taking something out of the middle or putting something into the middle — for instance deleting the row a user just clicked.

The mistake to avoid is expecting splice to hand back the remaining array. It hands back the removed part; the array you called it on is what now holds the result. So const remaining = items.splice(2, 1) leaves remaining holding the single deleted item while items holds everything else — the opposite of what most people assume the first time they read it.

Example
const subjects = ['Maths', 'Physics', 'Chemistry', 'Biology', 'English'];

// slice: copies out, original untouched, the end index is exclusive
console.log(subjects.slice(1, 3));   // ['Physics', 'Chemistry']
console.log(subjects.slice(-2));     // ['Biology', 'English']
console.log(subjects.slice());       // a shallow copy of the whole array
console.log(subjects.length);        // 5 - nothing was changed

// splice: changes the array in place, returns what it removed
const removed = subjects.splice(1, 2);
console.log(removed);                // ['Physics', 'Chemistry']
console.log(subjects);               // ['Maths', 'Biology', 'English']

// splice can insert as well: remove nothing, add at index 1
subjects.splice(1, 0, 'Hindi');
console.log(subjects);               // ['Maths', 'Hindi', 'Biology', 'English']

// The common real use: remove one item by its index
const items = ['a', 'b', 'c'];
items.splice(1, 1);
console.log(items);                  // ['a', 'c']
  • slice — returns a new array; the original is untouched; the second argument is exclusive
  • splice — changes the original; returns only the removed elements
  • slice() with no arguments is a quick shallow copy
  • slice(-n) takes the last n elements
  • splice(i, 1) removes one element at index i
  • splice(i, 0, x) inserts x at index i without removing anything
Notes
  • A memory hook that works: splice has a p in it, for "permanent change". slice only ever takes a copy.

Copying, Comparing and the sort() Trap

Everything the previous lesson said about object references applies to arrays, because arrays are objects. const b = a gives you a second name for the same array, not a copy. [1, 2] === [1, 2] is false, because === on arrays compares identity rather than contents. To compare contents you have to walk both arrays, or — for simple values only — compare JSON.stringify of each, which is quick but breaks the moment order or types differ.

Copying uses spread, [...arr], or arr.slice(). Both are shallow. If the array holds objects, both copies contain the very same object references, so editing copy[0].name also changes original[0].name. That is a constant source of bugs in list-editing code. The fix is structuredClone(arr), or copying each object as you go with arr.map(o => ({ ...o })).

Now the trap that catches everyone at least once. sort() with no arguments converts every element to a string and sorts alphabetically. On numbers that produces nonsense: [10, 9, 100, 1].sort() gives [1, 10, 100, 9], because the string '10' sorts before the string '9'. To sort numbers you must pass a comparator — (a, b) => a - b for ascending, (a, b) => b - a for descending. A comparator's job is to return a negative number, zero, or a positive number, and subtraction happens to do exactly that.

sort and reverse both change the original array and then return it, which means const sorted = list.sort(...) leaves you with a sorted array and a sorted list — your original ordering is gone, and nothing warned you. If you need to keep the original, copy first with [...list].sort(...). Current browsers and Node 20 and later also provide toSorted and toReversed, which do the same work without mutating; check what your project supports before relying on them.

Example
const marks = [10, 9, 100, 1];

// The default sort compares elements as strings
console.log([...marks].sort());                 // [1, 10, 100, 9]

// A comparator sorts numbers properly
console.log([...marks].sort((a, b) => a - b));  // [1, 9, 10, 100]
console.log([...marks].sort((a, b) => b - a));  // [100, 10, 9, 1]

// sort mutates - watch what happens to the original
const list = [3, 1, 2];
const sorted = list.sort((a, b) => a - b);
console.log(sorted);           // [1, 2, 3]
console.log(list);             // [1, 2, 3]  <- the original changed too
console.log(sorted === list);  // true - they are the same array

// Sorting objects by a field
const students = [
  { name: 'Ananya', marks: 82 },
  { name: 'Rahul', marks: 68 },
  { name: 'Priya', marks: 91 }
];
const byMarks = [...students].sort((a, b) => b.marks - a.marks);
console.log(byMarks[0].name);  // 'Priya'

// Arrays are compared by identity
console.log([1, 2] === [1, 2]);   // false
Notes
  • To sort text properly, use arr.sort((a, b) => a.localeCompare(b)). The plain comparison operators sort by character code, which places every capital letter before every lowercase one and handles non-English text badly.

Looping Over an Array

There are four loops you will see in real code, and each has a job. The classic counting for loop gives you the index and complete control — you can skip elements, step backwards, or break out early. for...of hands you each value directly and is the cleanest choice when you do not need the index; it supports break and continue too.

forEach calls a function once per element, passing the value, the index and the whole array. It reads nicely, and it has one limitation that matters: you cannot break out of it. A return inside a forEach callback only ends that single call and moves on to the next element, which looks like a broken break to anyone expecting one. If you need to stop early, use for...of, or a method built for the purpose such as find or some from Lesson 10.

The second forEach limitation appears the moment you meet asynchronous code in Lesson 22: forEach does not wait for an async callback. It starts every call and returns immediately, so the code written after your loop runs before any of the work has actually finished. This is one of the most common asynchronous bugs in JavaScript, and the fix is an ordinary for...of loop, which does wait.

Finally, for...in belongs to objects, not arrays. Used on an array it gives you the indexes as strings — '0', '1', '2' — and it will also walk any extra properties someone attached to the array. Use for...of for values, a counting for loop when you need indexes, and keep for...in for the object case where it actually belongs.

Example
const subjects = ['Maths', 'Physics', 'Chemistry'];

// Classic for: index available, full control, break and continue work
for (let i = 0; i < subjects.length; i++) {
  if (subjects[i] === 'Physics') continue;
  console.log(i, subjects[i]);
}

// for...of: values directly, break works
for (const subject of subjects) {
  if (subject === 'Chemistry') break;
  console.log(subject);
}

// Need both index and value? entries() gives you a pair
for (const [i, subject] of subjects.entries()) {
  console.log(`${i + 1}. ${subject}`);
}

// forEach: reads well, but there is no way to break out
subjects.forEach((subject, index) => {
  console.log(index, subject);
});

// for...in on an array gives STRING indexes - avoid it here
for (const i in subjects) {
  console.log(typeof i);    // 'string' every time
}
Notes
  • Nested arrays are simply arrays inside arrays, and they are how you represent a grid or a table: grid[1][2] reads row 1, column 2. To flatten one level use arr.flat(), and to flatten all the way down use arr.flat(Infinity).
Ask AI