Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
What will I learn
- You will learn how to create arrays and access their elements, including the modern
.at(); - why arrays are objects under the hood, and what that means for
lengthand "holes"; - the mutating methods (
push,pop,shift,unshift,splice) that change an array in place; - the non-mutating methods (
slice,concat, spread) that return new arrays; - a first real look at
map,filter, andreduce, the three methods that change how you write JavaScript; - how JavaScript's arrays compare to Python lists, Rust vectors, and Go slices.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-10 read, so loops, functions, scope, and closures are familiar.
Difficulty
- Beginner
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods (this post)
Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
Solutions to Episode 10 Exercises
As always, we open with the worked solutions to last episode's three exercises. Do not just skim them -- type them out, run them, and compare the result against your own attempt. That comparison is where the understanding actually lands.
Exercise 1 - the scope chain direction:
function outerFn() {
const secret = "hidden";
function innerFn() {
console.log(secret); // works: inner sees outer
}
innerFn();
}
outerFn();
// console.log(secret); // ERROR (runtime): secret is not defined out here
The insight: the scope chain only searches outward, so innerFn can reach secret sitting one level up, but the global scope cannot reach inward into outerFn. Visibility is a one-way street, from inside looking out.
Exercise 2 - shadowing without interference:
const total = 100;
function scoped() {
const total = 5; // shadows the outer 'total' in here
console.log(total); // 5
}
scoped();
console.log(total); // 100 - the outer one is untouched
The insight: the inner total is a completely separate binding that hides the outer one only within scoped. The two never collide because they live in different scopes.
Exercise 3 - a greeter closure:
function makeGreeter(name) {
return function (greeting) {
return `${greeting}, ${name}!`;
};
}
const hi = makeGreeter("scipio");
console.log(hi("Hello")); // "Hello, scipio!"
The insight: the returned function closes over name, remembering it long after makeGreeter has returned. That captured name is the closure at work -- exactly the idea we spent all of episode 10 building toward.
Today we meet the single most-used data structure in JavaScript: the array. If closures were the deep idea, arrays are the everyday tool -- you will reach for them in almost every program you ever write.
Creating and reading arrays
An array is an ordered list of values. You write one with square brackets, and the values can be any type, even mixed together in the same array:
const numbers = [10, 20, 30, 40];
const mixed = [1, "two", true, null];
const empty = [];
console.log(numbers[0]); // 10 - indexing starts at zero
console.log(numbers[3]); // 40
console.log(numbers.length); // 4
Arrays are zero-indexed: the first element is at position 0, so a four-element array runs from index 0 to index 3. Reading past the end gives undefined, not an error, which is a small mercy and an occasional trap. A handy modern method is .at(), which accepts negative indices to count from the end:
console.log(numbers.at(-1)); // 40 - the last element
console.log(numbers.at(-2)); // 30 - second from last
console.log(numbers[10]); // undefined - out of range, no crash
Before .at() existed you had to write numbers[numbers.length - 1] to get the last element, which is clumsy and easy to get wrong by one. .at(-1) says exactly what you mean. Small quality-of-life wins like this add up across a codebase.
There are a few other ways to build an array besides the literal. Array.from turns anything iterable (a string, a Set, a range-like object) into a real array, and Array.of builds one from its arguments. You will not need these constantly, but they solve real problems:
console.log(Array.from("abc")); // ["a", "b", "c"]
console.log(Array.from({ length: 3 }, (_, i) => i * 10)); // [0, 10, 20]
console.log(Array.of(7)); // [7] - one element, on purpose
That second Array.from call is a genuinely useful trick: give it a length and a mapping function and it builds a sequence for you, no manual loop needed. The _ is just a conventional name for a parameter we do not use (here, the element itself, which is undefined for an empty slot), while i is the index.
Arrays are really objects
Here is a truth that explains a lot of array behaviour: in JavaScript an array is a special kind of object whose keys are numeric indices, plus a length property that tracks the highest index plus one. This is why typeof [] === "object", and why you must use Array.isArray() to check for a real array rather than trusting typeof:
console.log(typeof [1, 2, 3]); // "object" - not "array"!
console.log(Array.isArray([1, 2])); // true - the correct check
console.log(Array.isArray("nope")); // false
console.log(Array.isArray({ 0: "a", length: 1 })); // false - looks arrayish, isn't
Because length is a writable property, assigning to it can truncate an array, and assigning an element far past the end creates holes (empty slots that are not quite the same as undefined). You rarely do either on purpose, but seeing it once demystifies a whole category of "wait, what?" moments:
const arr = [1, 2, 3, 4, 5];
arr.length = 3;
console.log(arr); // [1, 2, 3] - truncated by writing to length
const holey = [1, 2];
holey[5] = 99;
console.log(holey); // [1, 2, <3 empty items>, 99]
console.log(holey.length); // 6 - length jumped to fit the new index
console.log(holey[3]); // undefined - the hole reads as undefined
The practical lesson: keep your arrays dense (no gaps), let the methods manage length for you, and you will never trip over holes. But it is good to know the machinery is just an object underneath, because that is why arrays behave the way they do.
Mutating methods: changing the array in place
Several methods modify the array they are called on. The four you will use constantly work at the two ends of the array:
const stack = [1, 2, 3];
stack.push(4); // add to the END -> [1, 2, 3, 4], returns new length 4
stack.pop(); // remove from the END -> [1, 2, 3], returns 4
stack.unshift(0); // add to the START -> [0, 1, 2, 3]
stack.shift(); // remove from the START -> [1, 2, 3], returns 0
console.log(stack); // [1, 2, 3]
Note the return values, because they are easy to forget: push returns the new length, while pop and shift return the element they removed. push/pop operate at the end (fast, because nothing else has to move), whereas unshift/shift operate at the start (slower, because every remaining element shifts down a slot -- something we will care about much later in the performance phase). For surgery in the middle, splice can remove and insert at any position at once:
const letters = ["a", "b", "e"];
letters.splice(2, 0, "c", "d"); // at index 2, remove 0, then insert "c","d"
console.log(letters); // ["a", "b", "c", "d", "e"]
letters.splice(0, 1); // at index 0, remove 1 element
console.log(letters); // ["b", "c", "d", "e"]
Read splice(start, deleteCount, ...itemsToInsert): start here, delete this many, then drop these in. It returns an array of whatever it removed. These methods all change the original array, which is powerful but also a classic source of bugs when you did not mean to mutate data that something else was still holding a reference to. That risk brings us straight to the alternative.
Non-mutating methods: returning new arrays
Often you want a modified copy while leaving the original intact. slice extracts a section without changing the source (note the one-letter difference: slice copies, splice mutates -- that single letter has caused a lot of confusion over the years):
const original = [1, 2, 3, 4, 5];
const middle = original.slice(1, 4); // from index 1 up to (not incl.) 4
console.log(middle); // [2, 3, 4]
console.log(original); // [1, 2, 3, 4, 5] - untouched
concat joins arrays into a new one without touching the originals, and the spread operator ... (which we first met with functions in episode 9) is the modern, readable way to do the same thing:
const a = [1, 2];
const b = [3, 4];
console.log(a.concat(b)); // [1, 2, 3, 4] - classic way
const combined = [...a, ...b]; // [1, 2, 3, 4] - modern spread
const copy = [...a]; // a shallow copy of a
const withExtra = [...a, 99]; // [1, 2, 99] - copy plus one more
console.log(combined, copy, withExtra);
One word of warning on copy there: it is a shallow copy. The array itself is new, but if it held objects, both arrays would still point at the same objects. We will look hard at shallow versus deep copying in a later episode -- for now, just file away that [...a] gives you a fresh top-level array.
Preferring non-mutating operations (copy first, then change the copy) is a habit that prevents a whole class of "why did this other variable change?" bugs. It is such a central idea that JavaScript recently added copying twins of the old mutating methods -- toSorted, toReversed, toSpliced, and with -- so you can sort or reverse without wrecking the original:
const scores = [30, 10, 20];
const sorted = scores.toSorted((x, y) => x - y); // [10, 20, 30]
console.log(scores); // [30, 10, 20] - the original is safe
const patched = scores.with(0, 99); // [99, 10, 20] - copy with index 0 replaced
console.log(patched);
We will make immutability a central theme when we reach the functional-programming phase. For now, just notice how much calmer code becomes when your data stops changing behind your back.
The big three: map, filter, reduce
Now the methods that genuinely change how you write JavaScript. Instead of manually writing a loop, creating an empty array, and pushing into it, you describe the transformation and let the method drive the loop. These each get a full episode later, but you should meet them now, because you will see them everywhere.
map makes a new array by transforming every element through a function you give it:
const nums = [1, 2, 3, 4];
const doubled = nums.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8]
console.log(nums); // [1, 2, 3, 4] - map does NOT mutate
Compare that to the loop you would have written before this episode -- const doubled = []; for (const n of nums) doubled.push(n * 2); -- and you can feel the difference. The map version has no bookkeeping, no chance of an off-by-one, and it reads like a sentence: "the doubled values are each n times two".
filter makes a new array keeping only the elements that pass a test (the callback returns true to keep, false to drop):
const values = [5, 12, 8, 130, 44];
const big = values.filter((n) => n > 10);
console.log(big); // [12, 130, 44]
reduce is the powerful one, and the least obvious. It boils an array down to a single value by carrying an accumulator along from element to element:
const prices = [10, 20, 30];
const total = prices.reduce((sum, price) => sum + price, 0);
console.log(total); // 60 - started at 0, added each price
Read reduce as: start the accumulator at 0 (that second argument), and for each price, produce the new accumulator sum + price. Step by step it goes 0 + 10 = 10, then 10 + 20 = 30, then 30 + 30 = 60. The final accumulator is the result. It earns its own episode because once it clicks, you realise map and filter are both just special cases of it. Here is reduce doing something a little richer -- tallying how many times each word appears:
const words = ["a", "b", "a", "c", "b", "a"];
const counts = words.reduce((tally, word) => {
tally[word] = (tally[word] ?? 0) + 1; // ?? default from episode 4
return tally;
}, {}); // start with an empty object as the accumulator
console.log(counts); // { a: 3, b: 2, c: 1 }
Notice the accumulator does not have to be a number -- here it is an object we build up. That flexibility is exactly why reduce is the Swiss army knife of array methods. For now, just feel the shift: no manual index, no push, you state what you want and the method handles the how.
Iterating and chaining
You already know for...of from episode 8, and it works beautifully on arrays. But there is also forEach, which runs a function for every element purely for its side effect (it returns nothing -- use it when you want to do something, not build something):
const names = ["ada", "linus", "grace"];
names.forEach((name, index) => {
console.log(`${index}: ${name}`);
});
// 0: ada
// 1: linus
// 2: grace
The real payoff of these methods is that they chain. Because map and filter each return a new array, you can line them up left to right and read the pipeline top to bottom:
const nums2 = [1, 2, 3, 4, 5, 6];
const result = nums2
.filter((n) => n % 2 === 0) // keep evens -> [2, 4, 6]
.map((n) => n * n) // square them -> [4, 16, 36]
.reduce((sum, n) => sum + n, 0); // add them -> 56
console.log(result); // 56
That is three operations with no intermediate variables and no loops, and it reads almost like English: keep the evens, square them, add them up. This declarative style is a huge part of what modern JavaScript feels like day to day.
Finding and testing
Rounding out the everyday toolkit, these methods answer "is it there?" and "where?":
const list = [3, 7, 9, 12];
console.log(list.includes(9)); // true - membership test
console.log(list.indexOf(7)); // 1 - position, or -1 if absent
console.log(list.find((n) => n > 8)); // 9 - first element passing the test
console.log(list.findIndex((n) => n > 8)); // 2 - its index
console.log(list.some((n) => n > 10)); // true - does AT LEAST ONE match?
console.log(list.every((n) => n > 0)); // true - do ALL match?
find returns the first element passing a test (or undefined if none do), findIndex returns its position, some asks "does at least one match", and every asks "do they all match". A small gotcha worth remembering: indexOf uses strict equality (===, from episode 4), so [NaN].indexOf(NaN) is -1 because NaN is never equal to itself, whereas includes handles NaN correctly. Together with the big three, these cover the vast majority of array work you will ever do.
How arrays compare to Python, Rust, and Go
Many of you came here from the Learn Python Series, and some from Learn Rust, so a glance sideways is worth it -- it shows which of JavaScript's array choices are universal and which are its own flavour.
Python lists are the closest cousin. They are also ordered, also zero-indexed, and also grow and shrink freely. Python even has negative indexing built into the subscript itself (nums[-1]), which is where JavaScript's newer .at(-1) clearly took its inspiration. The big difference is style: Python leans on list comprehensions where JavaScript leans on map/filter:
# Python: a list comprehension does map + filter in one expression
nums = [1, 2, 3, 4, 5, 6]
result = [n * n for n in nums if n % 2 == 0] # [4, 16, 36]
print(result)
That single comprehension is Python's answer to our .filter(...).map(...) chain. Both are declarative; they just spell it differently. Python's sum(...), any(...), and all(...) line up neatly with JavaScript's reduce, some, and every.
Rust vectors (Vec<T>) are stricter in a way that prevents whole classes of bugs. A Vec can only hold one type (no mixing numbers and strings like our mixed array), and indexing out of range panics rather than quietly handing you undefined. Rust's iterator adapters, though, will look instantly familiar -- they are map and filter by another name, just lazy until you collect:
fn main() {
let nums = vec![1, 2, 3, 4, 5, 6];
let result: Vec<i32> = nums.iter()
.filter(|&n| n % 2 == 0)
.map(|n| n * n)
.collect();
println!("{:?}", result); // [4, 16, 36]
}
See the same shape? filter then map then gather the results. The ideas transfer directly; only the ownership rules and the explicit types are new.
Go slices are the lower-level take. A slice is a view into a backing array with a length and a capacity, and Go deliberately gives you no built-in map/filter/reduce -- you write the loop yourself, every time. That is a philosophy choice: Go values one obvious way to do things over a rich method toolbox:
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
result := []int{}
for _, n := range nums {
if n%2 == 0 {
result = append(result, n*n)
}
}
fmt.Println(result) // [4 16 36]
}
The throughline: nearly every language gives you a growable ordered list, but they differ on how much they hand you for free. JavaScript sits toward the generous end -- a big built-in method set, dynamic typing that lets arrays hold anything, and forgiving out-of-range reads. That generosity is why arrays feel so effortless in JS, and also why a little discipline (keep them dense, prefer non-mutating methods) goes a long way.
Try it yourself
- Start with
const queue = ["a", "b", "c"]. Add"d"to the end, remove the first element, and print both the resulting array and the removed value. In a comment, say which of the two methods you used mutate the array. - Given
const temps = [15, 22, 8, 30, 19], usefilterto keep temperatures above 18, then chainmapto append the string" C"to each, producing["22 C", "30 C", "19 C"]. Print it. - Use
reduceto find the maximum value in[4, 9, 2, 15, 7]without usingMath.max. (Hint: the accumulator holds the biggest value seen so far; start it at the first element, or at-Infinity.)
So what did we actually cover?
- Arrays are ordered, zero-indexed lists;
.at(-1)reads from the end, and out-of-range access givesundefinedrather than an error. - An array is really an object with numeric keys and a writable
length; check withArray.isArray, nottypeof, and keep arrays dense to avoid holes. - Mutating methods change the array in place:
push/pop(end),unshift/shift(start), andsplice(anywhere). Mind their return values. - Non-mutating methods return copies:
slice,concat, the spread operator..., and the newertoSorted/toReversed/with; prefer these to avoid accidental shared-state bugs. - The big three transform declaratively:
map(transform each),filter(keep matching),reduce(fold everything down to one value), and they chain cleanly. includes,indexOf,find,findIndex,some, andeveryanswer membership and testing questions.- Python lists, Rust vectors, and Go slices solve the same problem with different amounts of built-in help; JavaScript sits at the generous, forgiving end.
That finishes the foundations of Phase 1: you now understand how JavaScript stores single values and how it stores lists of them. Next episode we cover the other half of JavaScript's data world -- objects, the key-value structure that models real-world things, plus dot versus bracket access and how nesting works.