A JavaScript array is an ordinary object whose keys happen to be the strings "0", "1", "2"… plus a special length property that the engine keeps in sync. Its methods fall into two families: methods that edit the array in place and methods that leave it alone and hand you something new. Mixing them up is the source of most array bugs — sorting a prop in React, splice-ing an array another part of the code is still iterating, or expecting push to return the array.
The most important column: does it change the original array?
Method
Returns
Mutates?
push(x) / unshift(x)
New length
✅ adds to end / start
pop() / shift()
Removed item
✅ removes from end / start
splice(i, n, ...items)
Removed items
✅
sort(fn) / reverse()
The same array
✅
fill(v) / copyWithin()
The same array
✅
map(fn)
New array, same length
❌
filter(fn)
New array of matches
❌
reduce(fn, init)
Single value
❌
slice(start, end)
New sub-array
❌
concat(...arrs)
New array
❌
flat(depth) / flatMap(fn)
New array
❌
toSorted / toReversed / toSpliced / with
New array
❌ (ES2023)
find / findLast
First / last matching item or undefined
❌
findIndex / findLastIndex
Index or -1
❌
indexOf(x) / includes(x)
Index or -1 / boolean
❌
some(fn) / every(fn)
Boolean
❌
forEach(fn)
undefined
❌
join(sep)
String
❌
at(i)
Item (negative counts from end)
❌
Read the "Returns" column as carefully as the "Mutates?" column. sort and reverse return the same array object they were called on, so const sorted = arr.sort() gives you two names for one array — sorted === arr is true, and the original order is gone. push returns the new length, not the array, which is why return arr.push(x) inside a reduce is a classic bug. And "❌" only means the array isn't changed: map and slice copy the slots, but if those slots hold objects, the new array points at the same objects (a shallow copy — see Shallow vs deep copy).
These are the three workhorse higher-order functions: each takes a callback, calls it once per element, and builds a result from what the callback returns. None of them changes the original array. That's the point — instead of writing a for loop that pushes into a result variable, you describe what each element becomes, and the method handles the looping and the result array.
A useful mental model is a production line. map is a station that reshapes every item (same number in, same number out). filter is a gate that lets some items through (same or fewer out, each unchanged). reduce is the packing station at the end that folds everything into a single thing — a number, an object, a string, even another array.
All three pass the callback the same arguments: (element, index, array). reduce adds one in front: (accumulator, element, index, array).
JavaScript
const products =[{name:"Laptop",price:1200,inStock:true},{name:"Mouse",price:25,inStock:false},{name:"Monitor",price:300,inStock:true},];// map: transform every item → same lengthconst names = products.map((p)=> p.name);// ["Laptop", "Mouse", "Monitor"]// filter: keep items that pass → same or shorterconst available = products.filter((p)=> p.inStock);// reduce: boil the array down to one valueconst total = products.reduce((sum, p)=> sum + p.price,0);// 1525
What’s happening
map creates an empty result array, then calls (p) => p.name for each product and stores whatever comes back at the same index: "Laptop", "Mouse", "Monitor". Three in, three out — map never drops an item.
filter calls (p) => p.inStock for each product. The callback returns true, false, true, so the Laptop and Monitor objects are copied into the new array and the Mouse is skipped. available is [Laptop, Monitor] — these are the same objects as in products, not copies, so available[0].price = 1 would also change products[0].
reduce starts with the accumulator sum set to the initial value 0. Iteration 1: sum = 0, p.price = 1200, returns 1200. Iteration 2: sum = 1200, p.price = 25, returns 1225. Iteration 3: sum = 1225, p.price = 300, returns 1525.
Whatever the callback returns becomes sum for the next call; after the last element, that value is what reduce itself returns. So total is 1525.
products is untouched by all three calls. That's why these methods are safe to use on props and state — they read the input and build new output.
AdvancedFilter then map in one chain
Chaining filter and map
Because map and filter both return arrays, you can call the next method directly on the result. Each step in the chain receives the output of the previous step, so the code reads top to bottom like a pipeline.
products.filter((p) => p.inStock) runs first over all three products and returns a temporary array [Laptop, Monitor].
.map(...) is called on that temporary array, not on products. It only sees two items, so the callback runs twice: "Laptop".toUpperCase() → "LAPTOP", "Monitor".toUpperCase() → "MONITOR".
The temporary filtered array is never assigned to a variable, so it's garbage-collected once the chain finishes. inStockNames is ["LAPTOP", "MONITOR"].
Order matters: filtering first means map does less work. If you mapped first you'd lose the inStock field (you'd have only strings left), so you couldn't filter on it afterwards.
The cost is one intermediate array per step. For a few thousand items that's irrelevant; for huge arrays in hot code, a single reduce or a plain for...of loop does it in one pass.
AdvancedGeneral-purpose reduce accumulators
reduce beyond sums
reduce is the most general of the three: the accumulator can be any type, so anything you'd write as "start with an empty X, loop, update X" can be a reduce. The rule is simple — the callback must return the new accumulator every time. Forget the return (easy to do with a block body { … }) and the next iteration gets undefined.
JavaScript
// Count occurrencesconst fruits =["apple","banana","apple","cherry","apple"];const counts = fruits.reduce((acc, f)=>{
acc[f]=(acc[f]??0)+1;return acc;},{});// { apple: 3, banana: 1, cherry: 1 }// Index by idconst byName = products.reduce((acc, p)=>({...acc,[p.name]: p }),{});// Max valueconst priciest = products.reduce((max, p)=>(p.price > max.price ? p : max));
The return acc line is essential: it hands the same object to the next iteration. Mutating acc here is fine because it's a fresh {} that reduce owns — nothing outside can see it until it's finished.
Index by id. The arrow returns a new object each time: { ...acc, [p.name]: p } copies every key so far, then adds one more using a computed key. After three iterations the keys are Laptop, Mouse, Monitor, each pointing at its product object. The parentheses around { … } are needed so the braces read as an object literal, not a function body.
That spread copies all previous keys on every iteration, so the total work grows with the square of the array length. Fine for 50 items, slow for 50,000 — mutate acc and return it (as in the count example), or write Object.fromEntries(products.map((p) => [p.name, p])).
Max value. There's no initial value, so reduce uses the first element (Laptop) as max and starts at index 1. Call 1: max = Laptop (1200), p = Mouse (25) → keeps Laptop. Call 2: max = Laptop, p = Monitor (300) → keeps Laptop. priciest is the Laptop object. Skipping the initial value is reasonable here because there's no sensible "starting product" — but it throws on an empty array (next gotcha).
?Predict the output
JavaScript
console.log(["1","2","3"].map(parseInt));
Show answer
[1, NaN, NaN].
map passes three arguments: (value, index, array). So the calls are parseInt("1", 0) → 1 (radix 0 means "auto"), parseInt("2", 1) → NaN (radix 1 is invalid), parseInt("3", 2) → NaN ("3" isn't a binary digit). Use .map(Number) or .map((s) => parseInt(s, 10)).
What’s happening
Passing parseInt directly is the same as writing .map((value, index, array) => parseInt(value, index, array)). map doesn't know parseInt only wants one argument — it always passes all three.
parseInt has the signature parseInt(string, radix), so the index lands in the radix slot. The third argument (the array) is simply ignored.
Index 0: parseInt("1", 0). A radix of 0 (or undefined) means "decide for me": base 10, or base 16 if the string starts with 0x. Result 1.
Index 1: parseInt("2", 1). Valid radixes are 2 to 36; base 1 doesn't exist, so the result is NaN.
Index 2: parseInt("3", 2). Base 2 only has the digits 0 and 1. parseInt reads digits until it hits an invalid one, and the very first character is invalid, so there's nothing to parse → NaN. (["10", "10", "10"].map(parseInt) gives [10, NaN, 2] — the last one is binary 10.)
.map(Number) works because Number only looks at its first argument. The general lesson: only pass a function by reference to map/forEach/filter if you know it ignores extra arguments.
AdvancedChoosing map, filter or reduce
When to use which: map when every input produces exactly one output, filter when you want a subset, and reduce when the result is a different shape — a total, a lookup object, a grouped structure. If a reduce is getting hard to read, a for...of loop with a clearly named result variable is often clearer, and nobody in an interview will mark you down for it. Don't use map just to loop (if you ignore its return value, you wanted forEach or for...of).
Both take a test function, but they answer different questions. filter asks "which items pass?" and always returns an array. find asks "what's the first item that passes?" and returns that item itself (or undefined). Think of filter as a search that collects every result, and find as one that stops at the first hit.
users.find((u) => u.admin) calls the test on Rohit, gets true, and stops immediately — the callback runs once. It returns the Rohit object itself, not an array containing it.
users.filter((u) => u.admin) calls the test on all three users (true, false, true) and returns a new array holding the Rohit and Ana objects. It can't stop early, because a later item might also match.
users.find((u) => u.id === 9) tests all three, none match, so it returns undefined. Code like users.find(…).name will then throw TypeError: Cannot read properties of undefined — use optional chaining (users.find(…)?.name) when "not found" is possible.
The practical rule: looking up one thing by id → find. Wanting all matches → filter. Writing filter(…)[0] works but does extra work and reads worse than find.
AdvancedRepeated lookups: build a Map
If you do many lookups by id on the same array, neither is ideal — each call scans the array. Build a Map from id to item once (see Map deep dive) and each lookup becomes constant-time.
find gives you the item; sometimes you need where it is instead — to replace it, remove it, or insert next to it. findIndex is the position-returning twin of find, and findLast / findLastIndex do the same searches starting from the end. The "not found" values are what trips people up: the index methods return -1, the item methods return undefined.
JavaScript
const nums =[5,12,8,130,44];
nums.findIndex((n)=> n >10);// 1 (12)
nums.findLast((n)=> n >10);// 44
nums.findLastIndex((n)=> n >10);// 4
nums.findIndex((n)=> n >500);// -1// Typical use: update an item in placeconst i = users.findIndex((u)=> u.id ===2);if(i !==-1) users[i]={...users[i],name:"Samuel"};
What’s happening
findIndex((n) => n > 10) tests 5 (false), then 12 (true) and stops: index 1.
findLast((n) => n > 10) walks backwards from the end: 44 passes immediately, so it returns the value44. findLastIndex does the same walk but returns the index, 4.
findIndex((n) => n > 500) tests every element, none pass, so it returns -1.
The update reuses users from the previous section. findIndex finds Sam at index 1. The if (i !== -1) check matters: without it, a missing id would run users[-1] = …, which doesn't throw — it silently adds a property named "-1" to the array (arrays are objects), leaving length unchanged.
{ ...users[i], name: "Samuel" } builds a new object with Sam's fields and an overridden name, and stores it at index 1. The old Sam object isn't modified, but the usersarray is — slot 1 now points somewhere else. If users is React state, use users.with(i, { … }) or users.map(…) instead so you get a new array too.
AdvancedfindLast vs reversing a copy
findLast / findLastIndex (ES2023) search from the end — handy for "the most recent message from this user" without reversing a copy first.
Before ES2023 you'd write [...messages].reverse().find(…), which copies the whole array just to search it backwards (and messages.reverse().find(…) without the copy would mutate the original). findLast searches in place, allocates nothing, and stops at the first hit from the end.
These answer yes/no questions about the whole array. some is a logical OR across the elements ("does at least one pass?"), and every is a logical AND ("do they all pass?"). Both return a plain boolean, and both are lazy in the same way || and && are: once the answer is decided, they stop.
JavaScript
const ages =[19,22,17,30];
ages.some((a)=> a <18);// true — at least one minor
ages.every((a)=> a >=18);// false — not all adults[].some(()=>true);// false[].every(()=>false);// true — "vacuous truth": no item fails
What’s happening
some((a) => a < 18) tests 19 (false), 22 (false), 17 (true) — and stops. The callback runs 3 times; 30 is never looked at. Result true.
every((a) => a >= 18) tests 19 (true), 22 (true), 17 (false) — and stops, because one failure decides the answer. Also 3 calls. Result false.
[].some(() => true) is false: some starts by assuming "no" and only changes its mind when an element passes. With no elements, nothing passes.
[].every(() => false) is true: every starts by assuming "yes" and only changes its mind when an element fails. With no elements, nothing fails. This is vacuous truth, and it matters in real code — selected.every(isValid) is true when nothing is selected, so guard with selected.length > 0 && if an empty selection shouldn't count as valid.
AdvancedEarly exit with some and every
Both stop as soon as the answer is known (some at the first true, every at the first false), so they're also a way to "break" out of an iteration early.
That makes them a better fit than forEach for "check until you find a problem" logic. forEach has no way to stop — a return inside its callback only ends that one call, and the loop moves on to the next element. If you need early exit with side effects, prefer a for...of with break; using some purely for its stopping behaviour works but hides the intent.
Two methods with almost the same name and opposite behaviour, which is why interviewers love them. slice is a photocopier: it copies a range out of the array and leaves the original alone. splice is surgery: it cuts items out of the array itself, optionally stitches new ones in at the same spot, and hands you back what it removed.
Both take a start index, and both accept negative numbers counting from the end (-1 is the last element). The second argument is where they differ: for slice it's an end index (exclusive), for splice it's a count of items to delete.
a.slice(1, 3) copies from index 1 up to but not including index 3 — indexes 1 and 2 — giving [2, 3]. The number of items is always end - start.
a.slice(-2) turns -2 into length - 2 = 3 and, with no end, copies to the end: [4, 5]. a.slice() with no arguments copies everything — a new array (a.slice() === a is false) holding the same elements. Through all three calls, a stays [1, 2, 3, 4, 5].
fish.splice(2, 2) goes to index 2 ("blue") and deletes 2 items: "blue" and "trumpet". Everything after shifts left to close the gap, so fish becomes ["parrot", "anemone", "sturgeon"] (length 5 → 3), and the deleted items come back as removed = ["blue", "trumpet"].
fish.splice(1, 0, "clown") goes to index 1, deletes 0 items, and inserts "clown" there; everything from index 1 onwards shifts right. fish becomes ["parrot", "clown", "anemone", "sturgeon"]. The return value is [], since nothing was removed.
Delete and insert together replace items: on ["a", "b", "c", "d"], splice(1, 1, "X", "Y") removes "b" and puts two items in its place → ["a", "X", "Y", "c", "d"]. With only a start (splice(1)), it removes everything from that index to the end.
AdvancedMnemonic and uses for slice
Mnemonic: splice has a p for "permanent" — it changes the array.
In practice: use slice to take a page of results (items.slice(page * size, (page + 1) * size)), the last N entries (log.slice(-10)), or a quick shallow copy. Use splice only when you own the array and genuinely want to edit it — and never while a for loop is walking forwards over the same array, because removing an item shifts the next one into the index you've already passed. When you need the result as a new array (React state, function arguments you don't own), use toSpliced from the next section.
#Change-array-by-copy: toSorted, toReversed, toSpliced, with
ES2023 added non-mutating twins of the mutating methods. They're perfect for React state, where you must not mutate.
Before these existed, the non-mutating version of sort was "copy, then sort the copy": [...arr].sort(fn). Forgetting the copy was an easy, silent bug — the original array (often a prop or Redux state) got reordered underneath everything else that held it. The new methods bake the copy in. Each one returns a new array and never touches the original, and the names map one-to-one: sort → toSorted, reverse → toReversed, splice → toSpliced, and arr[i] = x → arr.with(i, x). They're in Node 20+ and all current browsers.
toSorted((a, b) => a - b) copies scores and sorts the copy numerically → [10, 20, 30]. It uses the same comparator rules as sort (see Sorting gotchas), including the string-comparison default if you leave the comparator out.
toReversed() reverses a copy of the original[30, 10, 20] → [20, 10, 30]. It doesn't see the sorted result from line 1, because that was a separate new array that nothing stored.
toSpliced(1, 1) takes the same arguments as splice — start at index 1, remove 1 item (10) — but returns the resulting array[30, 20], not the removed items. That's a key difference from splice, which returns what it deleted.
with(0, 99) returns a copy with index 0 replaced: [99, 10, 20]. Negative indexes count from the end (scores.with(-1, 0) → [30, 10, 0]), and an out-of-range index throws RangeError instead of quietly growing the array like arr[5] = x would.
The last line proves the point: scores is still [30, 10, 20] after all four calls.
AdvancedHoles left by delete arr[i]
Why not delete arr[i]?
delete is an object operator: it removes a property. Since array elements are just properties with numeric-string keys, delete arr[1] removes the property "1" — but it doesn't shift anything or update length. You're left with a gap that the engine calls a hole (an "empty" slot).
JavaScript
const arr =["a","b","c"];delete arr[1];
arr;// ["a", <empty>, "c"] — leaves a hole
arr.length;// 3 — length doesn't change// Remove properly — pick ONE of these:
arr.splice(1,1);// option 1: mutates arrconst next = arr.toSpliced(1,1);// option 2: new array, arr untouchedconst next2 = arr.filter((_, i)=> i !==1);// option 3: new array, arr untouched
What’s happening
delete arr[1] removes the property "1" from the array object. Reading arr[1] now gives undefined, but the slot isn't holding undefined — it doesn't exist at all: 1 in arr is false.
length stays 3, because delete knows nothing about arrays. Holes are then treated inconsistently: map, filter and forEachskip them, while for...of and spread yield undefined for them. [...arr] is ["a", undefined, "c"] — the hole silently becomes a real value.
The three "remove properly" lines are alternatives, not a sequence — pick one. Each removes index 1 and closes the gap so the result is ["a", "c"] with length 2.
arr.splice(1, 1) does it in place. arr.toSpliced(1, 1) returns a new array and leaves arr alone. arr.filter((_, i) => i !== 1) keeps every element whose index isn't 1 — the _ is a convention for "parameter I don't use". filter is also the natural choice for removing by value rather than by position (arr.filter((x) => x !== "b")).
If you did run all three lines in order, splice would have already shortened arr to ["a", "c"], so toSpliced(1, 1) would then give ["a"]. That's the mutation trap in miniature: after a mutating call, every later line sees a different array.
sort has two surprises. First, it mutates the array and returns the same object. Second, with no comparator it converts every element to a string and orders them by UTF-16 code units — dictionary order on characters, not numeric order. That default made sense for the original use case (sorting words) and can't be changed now without breaking the web.
A comparator is a function (a, b) => number that the engine calls on pairs of elements while it sorts. You don't control which pairs or in what order (that's up to the engine's algorithm — V8 uses TimSort); you only answer "which of these two goes first?". Negative means a first, positive means b first, 0 means "equal, keep their current order".
JavaScript
[10,1,5,100].sort();// [1, 10, 100, 5] 😬 — default sort compares strings[10,1,5,100].sort((a, b)=> a - b);// [1, 5, 10, 100][10,1,5,100].sort((a, b)=> b - a);// [100, 10, 5, 1]// Strings: use localeCompare for proper alphabetical order["résumé","apple","Zebra"].sort((a, b)=> a.localeCompare(b));// By a property, then by another
people.sort((a, b)=> a.lastName.localeCompare(b.lastName)|| a.age - b.age);
What’s happening
Default sort. The numbers become "10", "1", "5", "100" and are compared character by character. "1" is a prefix of "10", so it comes first; "10" is a prefix of "100"; and "100" beats "5" because the first characters already differ and "1" comes before "5". Result [1, 10, 100, 5].
(a, b) => a - b. If the engine passes a = 10, b = 1, the comparator returns 9 (positive) → b first, so 1 goes before 10. If it passes them the other way round, a = 1, b = 10, it returns -9 (negative) → a first — again 1 before 10. For 5 and 100 it returns -95 or 95, and either way 5 lands first. Every answer agrees with numeric order, so the result is [1, 5, 10, 100]. Swapping to b - a flips every sign, giving descending order [100, 10, 5, 1].
localeCompare. The default sort would give ["Zebra", "apple", "résumé"], because capital Z (code 90) sorts before every lowercase letter (97 and up). a.localeCompare(b) returns a negative number, positive number or 0 using the language's real alphabetical rules, which compare base letters first and only use accents and case to break ties. Result: ["apple", "résumé", "Zebra"].
Two keys. For two people with different last names, localeCompare returns non-zero and || uses that. For the same last name it returns 0, which is falsy, so || falls through to a.age - b.age. Sorting Smith 40, Jones 30, Smith 25 gives Jones 30, Smith 25, Smith 40. Add more || clauses for more tie-breakers.
All four calls reorder the array they're called on and return that same array. To keep the original, use toSorted with the same comparator.
AdvancedStable sort and multi-pass sorting
The comparator returns a negative number if a comes first, positive if b comes first, 0 if equal. Since ES2019 sort is guaranteed stable (equal items keep their order).
Stability is what makes multi-pass sorting work: sort by age, then sort by last name, and people with the same last name stay in age order. In the example above, two Jones 30 entries keep the order they had before sorting.
flat removes nesting: it takes an array that contains arrays and pours the inner elements up into the outer one. The depth argument says how many levels of brackets to remove (default 1). flatMap is map followed by flat(1) in a single pass — useful when each input should produce zero, one or many outputs, which plain map (always exactly one) can't express.
JavaScript
[1,[2,[3,[4]]]].flat();// [1, 2, [3, [4]]] — depth 1 by default[1,[2,[3,[4]]]].flat(2);// [1, 2, 3, [4]][1,[2,[3,[4]]]].flat(Infinity);// [1, 2, 3, 4]// flatMap = map then flat(1) — map one item to zero or manyconst sentences =["hello world","hi there"];
sentences.flatMap((s)=> s.split(" "));// ["hello", "world", "hi", "there"]// Filter and map in one pass: return [] to drop an item[1,2,3,4].flatMap((n)=>(n %2?[]:[n *10]));// [20, 40]
What’s happening
flat() with depth 1 looks at each element of the outer array: 1 isn't an array, so it's kept; [2, [3, [4]]] is, so its elements 2 and [3, [4]] are spread into the result. The inner arrays are left alone → [1, 2, [3, [4]]].
flat(2) repeats that one level deeper, unpacking [3, [4]] into 3 and [4] → [1, 2, 3, [4]]. flat(Infinity) keeps going until nothing is nested → [1, 2, 3, 4]. (flat also drops holes: [1, , 3].flat() is [1, 3].)
sentences.map((s) => s.split(" ")) would give [["hello", "world"], ["hi", "there"]] — an array of arrays. flatMap does the same map and then flattens one level, so you get the four words in one array.
The last example uses the "zero or one" trick. For odd numbers n % 2 is 1 (truthy), so the callback returns [] — an empty array that disappears when flattened. For even numbers it returns [n * 10], a one-item array that becomes a single element. 1 → [], 2 → [20], 3 → [], 4 → [40], flattened to [20, 40].
flatMap only flattens one level: if the callback returns [[n]], you get [n] arrays in the result, not numbers. If it returns a non-array value, that value is kept as one element.
AdvancedWhen to use the flatMap filter trick
The filter-and-map trick is neat but less obvious to readers than .filter(…).map(…); use it when you're also expanding some items into several, or when you want the condition and the transformation side by side.
Array.from(iterableOrArrayLike, mapFn?) builds a real array from anything iterable (Set, Map, string, NodeList) or array-like ({ length: n }, arguments).
"Iterable" means the object has a Symbol.iterator method (see Iterators and the iteration protocol) — that's what for...of and spread use. "Array-like" means it just has a length and numbered keys, like the old arguments object. Array.from handles both, which is why it's the general-purpose "turn this into an array" tool. The optional second argument is a map function (value, index), applied as each element is created, so you don't need a separate .map() and an intermediate array.
JavaScript
Array.from("abc");// ["a", "b", "c"]
Array.from(newSet([1,1,2]));// [1, 2]
Array.from(document.querySelectorAll("li"));// NodeList → Array// Create an array and fill it with index + 1
Array.from({length:5},(_, i)=> i +1);// [1, 2, 3, 4, 5][...Array(5).keys()].map((i)=> i +1);// same resultArray(5).fill(0);// [0, 0, 0, 0, 0]
What’s happening
Array.from("abc") uses the string's iterator, which yields one character at a time → ["a", "b", "c"]. Unlike "abc".split(""), the iterator understands emoji and other characters made of two UTF-16 units: Array.from("😀a") is ["😀", "a"], while split("") breaks the emoji in half.
Array.from(new Set([1, 1, 2])): the Set already dropped the duplicate 1, so iterating it yields 1, 2. querySelectorAll returns a NodeList, which has forEach but not map, filter or reduce — converting it gives you the full array toolkit.
Array.from({ length: 5 }, (_, i) => i + 1): the object has length: 5 and no numbered keys, so each element would be undefined. The map function ignores the value (_), uses the index i (0 to 4), and returns i + 1 → [1, 2, 3, 4, 5].
[...Array(5).keys()]: Array(5) is an empty array with length 5, .keys() yields the indexes 0 to 4 (even for holes), spread collects them, and map adds 1. Same result, one extra array.
Array(5).fill(0) creates the length-5 array and writes 0 into every slot → [0, 0, 0, 0, 0]. Watch the single-argument rule: Array(5) means "length 5", but Array(3, 4) means "the elements 3 and 4". Array.of(5) always means "an array containing 5".
Advanced2-D arrays and the fill trap
Creating a 2-D array
The classic grid bug comes from how fill works: it evaluates its argument once and writes that same value into every slot. For a number that's fine. For an array, it means every slot points to one shared array object.
JavaScript
// ✅ Each row is a new arrayconst grid = Array.from({length:3},()=>Array(3).fill(0));
grid[0][0]=1;// [[1,0,0],[0,0,0],[0,0,0]]// ❌ fill() puts the SAME array object in every rowconst bad =Array(3).fill(Array(3).fill(0));
bad[0][0]=1;// [[1,0,0],[1,0,0],[1,0,0]] — all rows changed!
What’s happening
In grid, the arrow () => Array(3).fill(0) is called three times, once per row, and each call creates a new [0, 0, 0]. The three rows are three different objects: grid[0] === grid[1] is false.
grid[0][0] = 1 changes only the first row → [[1,0,0],[0,0,0],[0,0,0]].
In bad, the inner Array(3).fill(0) is an argument, so it runs once, before the outer fill is called. The outer fill then stores a reference to that one array in all three slots: bad[0] === bad[1] is true.
bad[0][0] = 1 writes into that one shared row, and since every slot points to it, all three "rows" show the change.
The rule: whenever each slot needs its own object or array, create it inside a function that runs per slot (Array.from with a map function, or .map). fill is only safe for primitives.
JavaScript has four common ways to walk an array, and they differ in what they give you, whether you can stop early, and how they behave with await. The short version: for...of is the default for arrays and every other iterable; drop to a classic for when you need the index for arithmetic; use forEach for simple synchronous side effects; and keep for...in for plain objects.
Loop
Gives you
break / continue
await inside works
Use for
for (let i = 0; …)
Index
✅
✅
Index maths, going backwards
for...of
Values
✅
✅ (sequential)
Default choice for arrays & iterables
forEach
Value, index
❌
❌ (doesn't wait)
Simple side effects
for...in
Keys (strings!)
✅
✅
Objects, not arrays
JavaScript
const numbers =[1,2,3,4,5];for(const n of numbers) console.log(n);
numbers.forEach((n, i)=> console.log(i, n));for(const[i, n]of numbers.entries()) console.log(i, n);
What’s happening
for (const n of numbers) asks the array for its iterator and receives one value per iteration: 1, 2, 3, 4, 5. const is fine because each iteration gets a fresh binding.
numbers.forEach((n, i) => …) calls your function once per element with (value, index, array), logging 0 1, 1 2… It always returns undefined and always visits every element — a return only exits the current callback, and there's no break.
numbers.entries() returns an iterator of [index, value] pairs: the first is [0, 1]. Destructuring [i, n] in the loop head unpacks each pair, so you get the index and the ability to break, which forEach can't give you.
Holes are where they differ: on [1, , 3], forEach logs 1 and 3 (it skips the hole), while for...of logs 1, undefined, 3.
AdvancedValues vs keys: for...of and for...in
for...of vs for...in
These look alike but iterate completely different things. for...of uses the iteration protocol — it asks the object for its values in the order the object defines. for...in is much older and walks an object's enumerable string property keys, including inherited ones. On an array, those keys are "0", "1", "2" — and anything else that's been added as a property.
JavaScript
const arr =["apple","banana","cherry"];
arr.extra ="oops";for(const fruit of arr) console.log(fruit);// apple banana cherry (values)for(const key in arr) console.log(key);// "0" "1" "2" "extra" (keys, as strings!)const obj ={name:"John",age:30};for(const key in obj) console.log(key, obj[key]);// fine for objectsfor(const x of obj){}// TypeError: obj is not iterable
What’s happening
arr.extra = "oops" adds an ordinary property to the array object. It doesn't change length (still 3) because "extra" isn't an index.
for...of calls arr[Symbol.iterator](), and the array iterator only yields elements at indexes 0 to length - 1: apple, banana, cherry. The extra property is invisible to it.
for...in lists every enumerable property key: "0", "1", "2" and "extra". The keys are strings, so key + 1 gives "01", not 1. It would also list enumerable properties added to Array.prototype by a library — another reason to keep it away from arrays.
On the plain object, for...in is the right tool: key is "name" then "age", and obj[key] reads each value. (It does include inherited enumerable keys; see Iterating object properties with for...in.)
for (const x of obj) throws TypeError: obj is not iterable, because plain objects don't have a Symbol.iterator method. To loop an object's data with for...of, iterate Object.keys(obj), Object.values(obj) or Object.entries(obj), which are arrays.
for...of works on every iterable: arrays, strings, Maps, Sets, NodeLists, generators. forEach only exists on arrays, Maps, Sets and NodeLists — not on strings or plain objects.
(Typed arrays such as Uint8Array have forEach too, since they share most of the array methods.)
A Set is a collection of values where each value can appear only once. Adding something that's already there does nothing. That makes "remove duplicates" a one-liner: pour the array into a Set (duplicates vanish on the way in) and pour it back out into an array.
JavaScript
functionuniqueArray(array){return[...newSet(array)];// or Array.from(new Set(array))}uniqueArray([1,1,2,3,3]);// [1, 2, 3]
What’s happening
new Set(array) iterates the array and adds each value in turn: 1 (added), 1 (already present, ignored), 2, 3, 3 (ignored). The Set holds 1, 2, 3.
Sets remember insertion order, and a duplicate doesn't move the original. So the first occurrence of each value decides its position.
[...set] spreads the Set back into a new array using its iterator → [1, 2, 3]. Array.from(set) does exactly the same.
This is linear time: each add is a constant-time hash lookup on average. The old approach, array.filter((x, i) => array.indexOf(x) === i), scans the array for every element, which gets slow quadratically.
AdvancedSameValueZero and Set basics
A Set stores unique values (compared like ===, except NaN equals NaN) and keeps insertion order.
The formal name for that comparison is SameValueZero — the same rule includes uses, so new Set([NaN, NaN, 0, -0]) has two members, NaN and 0. Note that "1" and 1 are different values, so both can be in the same Set.
JavaScript
const tags =newSet(["js","ts"]);
tags.add("js");// ignored — already there
tags.has("ts");// true — O(1), much faster than array.includes on big lists
tags.delete("ts");
tags.size;// 1// Set operations (ES2025 — modern browsers and Node 22+)const a =newSet([1,2,3]);const b =newSet([2,3,4]);
a.union(b);// {1, 2, 3, 4}
a.intersection(b);// {2, 3}
a.difference(b);// {1}// Older equivalentconst intersection =newSet([...a].filter((x)=> b.has(x)));
What’s happening
tags starts as {"js", "ts"}. tags.add("js") finds "js" already present and changes nothing; add returns the Set itself, so you can chain set.add(1).add(2).
tags.has("ts") is a hash lookup, so it takes about the same time whether the Set holds 10 items or 10 million. array.includes has to scan element by element. If you check membership repeatedly against a big list, convert it to a Set once.
tags.delete("ts") removes it and returns true (it would return false if the value wasn't there). size is a property, not a method, and is now 1.
The ES2025 methods each return a new Set and leave a and b unchanged. union keeps everything from both → {1, 2, 3, 4}. intersection keeps what's in both → {2, 3}. difference keeps what's in a but not in b → {1}. There are also symmetricDifference ({1, 4}), isSubsetOf, isSupersetOf and isDisjointFrom.
The argument must be set-like (something with size, has and keys, such as a Set or a Map) — passing a plain array throws a TypeError. The "older equivalent" spreads a into an array, keeps the values b.has, and wraps the result in a new Set → {2, 3}. It's how you'd write it on Node 20 or older browsers.
A Map is a key-value collection where keys can be any type — objects, functions, numbers — and insertion order is preserved.
Plain objects were the only dictionary JavaScript had for twenty years, and they're a poor one: every key is turned into a string (obj[1] and obj["1"] are the same slot, and an object key becomes "[object Object]"), they inherit properties like toString from Object.prototype, and getting the size means building an array of keys. Map (ES2015) is a purpose-built dictionary. Keys keep their real type and are compared with SameValueZero — so 1 and "1" are different keys, and an object key matches only that exact object.
The mental model: an object is a record with a fixed set of known fields (user.name, user.email); a Map is a lookup table whose keys come from data and change at runtime (cache.get(url), countsByUserId.get(id)).
JavaScript
const person =newMap([["name","John"],["age",30],]);
person.set("occupation","Developer");
person.get("name");// "John"
person.has("age");// true
person.delete("age");
person.size;// 2// Object keys — impossible with plain objectsconst meta =newMap();const btn = document.querySelector("button");
meta.set(btn,{clicks:0});
meta.get(btn).clicks++;
What’s happening
new Map([...]) takes an iterable of [key, value] pairs and inserts them in order: name → "John", age → 30. This is the same shape Object.entries produces, which is why converting between the two is easy.
person.set("occupation", "Developer") adds a third entry at the end and returns the Map (so set calls can be chained). get("name") returns "John"; get on a missing key returns undefined. has("age") is true.
person.delete("age") removes that entry and returns true. Now the Map holds name and occupation, so size is 2.
meta.set(btn, { clicks: 0 }) uses the DOM element itself as the key. With a plain object, obj[btn] = … would turn the button into the string "[object HTMLButtonElement]", so every button would share one key.
meta.get(btn) returns the stored object, and .clicks++ mutates it in place → { clicks: 1 }. No set is needed afterwards because the Map holds a reference to that object, not a copy. Lookups are by identity: meta.get(document.querySelector("button")) finds it only because querySelector returns the same element object.
AdvancedMap vs plain object compared
Map
Plain object
Key types
Anything
Strings and symbols only
Order
Insertion order
Mostly insertion (integer keys first)
Size
map.size
Object.keys(obj).length
Iterable directly
Yes
No (use Object.entries)
Inherited keys
None
Has a prototype (toString, …) unless Object.create(null)
Frequent add/remove
Optimised for it
Slower
JSON support
No — convert first
Yes
Two rows deserve a closer look. "Integer keys first" means objects list integer-like keys in ascending numeric order before string keys in insertion order: Object.keys({ b: 1, 2: "x", a: 2, 1: "y" }) is ["1", "2", "b", "a"]. A Map would keep b, 2, a, 1. "Inherited keys" means "toString" in {} is true, so a naive if (key in counts) check misbehaves when the data contains a key like "toString" or "__proto__" (see Avoiding prototype pollution). Choose a Map when keys are dynamic or not strings, or you add and delete a lot; choose an object when the shape is known in advance or it has to go through JSON.stringify.
AdvancedMap iteration and its iterators
Iterating a Map
A Map is iterable, and iterating it yields [key, value] pairs in insertion order. It also has keys(), values() and entries() methods, which return iterators — lazy sequences, not arrays — so you spread them when you need an actual array.
JavaScript
for(const[key, value]of person) console.log(key, value);
person.forEach((value, key)=> console.log(key, value));// note: value first[...person.keys()];// ["name", "occupation"][...person.values()];// ["John", "Developer"][...person.entries()];// [["name","John"], ["occupation","Developer"]]// Convert both waysconst obj = Object.fromEntries(person);const map =newMap(Object.entries(obj));JSON.stringify([...person]);// serialise as an array of pairs
What’s happening
for...of over person gets ["name", "John"] then ["occupation", "Developer"], and the destructuring [key, value] unpacks each pair. This is the same as iterating person.entries().
person.forEach((value, key) => …) passes the value first, then the key (then the Map). It looks backwards, but it matches Array.prototype.forEach's (element, index, array) — the thing stored comes first, its "address" second.
keys(), values() and entries() each return an iterator; the spread pulls everything out into an array. The age entry deleted earlier is gone, so there are two of each.
Object.fromEntries(person) accepts any iterable of pairs, including a Map → { name: "John", occupation: "Developer" }. Non-string keys get stringified, so a Map with object keys collapses them all into "[object Object]". new Map(Object.entries(obj)) goes the other way.
JSON.stringify(person) gives "{}" — a Map has no enumerable own properties for JSON to see, so the data is silently lost. [...person] turns it into an array of pairs, which serialises as [["name","John"],["occupation","Developer"]], and new Map(JSON.parse(text)) rebuilds it.
Like Map/Set, but keys must be objects, and they're held weakly: if nothing else references the key object, it can be garbage-collected and the entry disappears automatically.
(Since ES2023, symbols created with Symbol() are allowed as keys too; primitives such as strings and numbers, and registered symbols from Symbol.for, throw TypeError: Invalid value used as weak map key.)
Why does this exist? A normal Map holds on to its keys. If you use DOM nodes or objects as keys in a long-lived Map, those objects can never be garbage-collected while they're in the Map, even after the rest of your app has forgotten them — a memory leak. A WeakMap holds its keys weakly: the entry doesn't count as a reason to keep the key alive. Once the key object is unreachable from anywhere else, the garbage collector can remove the key and its value together. The value is kept alive only for as long as its key is.
The mental model is a sticky note attached to someone else's object. You can write data on the note and read it back as long as you're holding the object. When the object is thrown away, the note goes with it, and you never have to clean up.
JavaScript
// Private data per instance without memory leaksconst privateData =newWeakMap();classUser{constructor(name, password){
privateData.set(this,{ password });this.name = name;}checkPassword(pw){return privateData.get(this).password === pw;}}// Cache results per object; entries vanish when the object doesconst cache =newWeakMap();functionexpensive(obj){if(!cache.has(obj)) cache.set(obj,heavyComputation(obj));return cache.get(obj);}// WeakSet: "have I seen this object?" — e.g. avoid processing a DOM node twiceconst processed =newWeakSet();functionprocess(node){if(processed.has(node))return;
processed.add(node);}
What’s happening
Private data.new User("Rohit", "s3cret") stores { password: "s3cret" } in privateData under the key this — the new instance itself — and puts only name on the instance. Logging the user shows User { name: 'Rohit' }; Object.keys, JSON.stringify and user.password can't see the password.
checkPassword("s3cret") looks up privateData.get(this) and compares → true; "nope" → false. The password is private to whoever can reach the privateData variable — in practice, the module it's declared in. When the user object is no longer referenced, its entry is collectable too, so creating thousands of users doesn't leak. This was the standard pattern before classes got #private fields (ES2022), and it's what transpilers still produce for #private on old targets.
Cache. The first expensive(obj) call misses (cache.has(obj) is false), runs heavyComputation once and stores the result keyed by that object. Later calls with the same object return the cached value without recomputing. A different object with identical contents is a different key and misses — the cache is by identity.
With a regular Map, every object ever passed in would stay in memory forever, because the Map's key holds it alive. With a WeakMap, when the rest of the app drops the object, the cache entry goes too — no eviction code needed.
WeakSet. The first process(node) sees processed.has(node) is false, adds the node, and carries on. A second call with the same node returns early. Removed DOM nodes drop out of the WeakSet automatically. (Naming the function process is fine in a browser; in Node it would shadow the global process object inside that scope.)
AdvancedWhy Weak collections aren't iterable
Trade-off: because entries can vanish at any time, Weak collections are not iterable and have no size — only get/set/has/delete (or add/has/delete).
If you could iterate a WeakMap or read its size, the answer would depend on when the garbage collector last ran, which is unpredictable — so the API simply doesn't offer it. That's also why you can't use a WeakMap as a general cache with string keys (use a Map with an eviction policy) or to list "all users" (use a Map or array). Reach for the Weak versions when the key is an object whose lifetime you don't control — DOM nodes, objects passed into a library function, instances created by someone else — and you want to attach data to it or remember having seen it. A common case is cycle detection in a deep clone: a WeakMap from "original object" to "its copy" (see Recursive deep clone).