JavaScript NotesRohit’s interview study guide
Chapter 05

Arrays & Collections

The array methods you'll use every day, which ones mutate, loops, and when to reach for Map, Set, WeakMap and WeakSet.

14 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Array cheat sheet

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?

MethodReturnsMutates?
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 / withNew array❌ (ES2023)
find / findLastFirst / last matching item or undefined❌
findIndex / findLastIndexIndex 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).

#map, filter and reduce

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 length
const names = products.map((p) => p.name); // ["Laptop", "Mouse", "Monitor"]

// filter: keep items that pass → same or shorter
const available = products.filter((p) => p.inStock);

// reduce: boil the array down to one value
const total = products.reduce((sum, p) => sum + p.price, 0); // 1525
What’s happening
  1. 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.
  2. 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].
  3. 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.
  4. 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.
  5. 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.

JavaScript
const inStockNames = products
  .filter((p) => p.inStock)
  .map((p) => p.name.toUpperCase()); // ["LAPTOP", "MONITOR"]
What’s happening
  1. products.filter((p) => p.inStock) runs first over all three products and returns a temporary array [Laptop, Monitor].
  2. .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".
  3. The temporary filtered array is never assigned to a variable, so it's garbage-collected once the chain finishes. inStockNames is ["LAPTOP", "MONITOR"].
  4. 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.
  5. 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 occurrences
const 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 id
const byName = products.reduce((acc, p) => ({ ...acc, [p.name]: p }), {});

// Max value
const priciest = products.reduce((max, p) => (p.price > max.price ? p : max));
What’s happening
  1. Count occurrences. acc starts as {}. "apple": acc.apple is undefined, ?? 0 turns it into 0, plus 1 → { apple: 1 }. "banana" → { apple: 1, banana: 1 }. "apple" → { apple: 2, banana: 1 }. "cherry" → { apple: 2, banana: 1, cherry: 1 }. "apple" → { apple: 3, banana: 1, cherry: 1 }.
  2. 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.
  3. 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.
  4. 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])).
  5. 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
  1. 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.
  2. parseInt has the signature parseInt(string, radix), so the index lands in the radix slot. The third argument (the array) is simply ignored.
  3. 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.
  4. Index 1: parseInt("2", 1). Valid radixes are 2 to 36; base 1 doesn't exist, so the result is NaN.
  5. 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.)
  6. .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).

#find vs filter

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.

findfilter
ReturnsThe first matching itemAn array of all matches
Nothing foundundefined[]
Stops earlyYes, at the first matchNo, checks everything
JavaScript
const users = [
  { id: 1, name: "Rohit", admin: true },
  { id: 2, name: "Sam", admin: false },
  { id: 3, name: "Ana", admin: true },
];

users.find((u) => u.admin);    // { id: 1, name: "Rohit", admin: true }
users.filter((u) => u.admin);  // [Rohit, Ana]
users.find((u) => u.id === 9); // undefined
What’s happening
  1. 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.
  2. 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.
  3. 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.
  4. 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.

#findIndex, findLast and findLastIndex

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 place
const i = users.findIndex((u) => u.id === 2);
if (i !== -1) users[i] = { ...users[i], name: "Samuel" };
What’s happening
  1. findIndex((n) => n > 10) tests 5 (false), then 12 (true) and stops: index 1.
  2. findLast((n) => n > 10) walks backwards from the end: 44 passes immediately, so it returns the value 44. findLastIndex does the same walk but returns the index, 4.
  3. findIndex((n) => n > 500) tests every element, none pass, so it returns -1.
  4. 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.
  5. { ...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 users array 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.

#some and every

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
  1. 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.
  2. every((a) => a >= 18) tests 19 (true), 22 (true), 17 (false) — and stops, because one failure decides the answer. Also 3 calls. Result false.
  3. [].some(() => true) is false: some starts by assuming "no" and only changes its mind when an element passes. With no elements, nothing passes.
  4. [].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.

#slice vs splice

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.

slice(start, end) — copies

JavaScript
const a = [1, 2, 3, 4, 5];
a.slice(1, 3);  // [2, 3]
a.slice(-2);    // [4, 5]
a.slice();      // shallow copy
a;              // [1, 2, 3, 4, 5] unchanged

splice(start, deleteCount, ...items) — edits in place

JavaScript
const fish = ["parrot", "anemone", "blue", "trumpet", "sturgeon"];
const removed = fish.splice(2, 2);
// fish    → ["parrot", "anemone", "sturgeon"]
// removed → ["blue", "trumpet"]

fish.splice(1, 0, "clown"); // insert, delete nothing
// ["parrot", "clown", "anemone", "sturgeon"]
What’s happening
  1. 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.
  2. 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].
  3. 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"].
  4. 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.
  5. 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.

JavaScript
const scores = [30, 10, 20];

scores.toSorted((a, b) => a - b); // [10, 20, 30]
scores.toReversed();              // [20, 10, 30]
scores.toSpliced(1, 1);           // [30, 20]
scores.with(0, 99);               // [99, 10, 20] — replace one index
scores;                           // [30, 10, 20] — untouched
What’s happening
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 arr
const next = arr.toSpliced(1, 1);            // option 2: new array, arr untouched
const next2 = arr.filter((_, i) => i !== 1); // option 3: new array, arr untouched
What’s happening
  1. 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.
  2. length stays 3, because delete knows nothing about arrays. Holes are then treated inconsistently: map, filter and forEach skip them, while for...of and spread yield undefined for them. [...arr] is ["a", undefined, "c"] — the hole silently becomes a real value.
  3. 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.
  4. 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")).
  5. 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.

#Sorting gotchas

Added

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
  1. 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].
  2. (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].
  3. 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"].
  4. 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.
  5. 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.

AdvancedPitfall: boolean comparators

#flat and flatMap

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 many
const 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
  1. 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]]].
  2. 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].)
  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.
  4. 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].
  5. 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 and creating arrays

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(new Set([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 result
Array(5).fill(0);                            // [0, 0, 0, 0, 0]
What’s happening
  1. 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.
  2. 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.
  3. 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].
  4. [...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.
  5. 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 array
const 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 row
const 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
  1. 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.
  2. grid[0][0] = 1 changes only the first row → [[1,0,0],[0,0,0],[0,0,0]].
  3. 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.
  4. bad[0][0] = 1 writes into that one shared row, and since every slot points to it, all three "rows" show the change.
  5. 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.

#Loops over arrays

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.

LoopGives youbreak / continueawait inside worksUse for
for (let i = 0; …)Index✅✅Index maths, going backwards
for...ofValues✅✅ (sequential)Default choice for arrays & iterables
forEachValue, index❌❌ (doesn't wait)Simple side effects
for...inKeys (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
  1. 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.
  2. 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.
  3. 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.
  4. 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 objects
for (const x of obj) {}                            // TypeError: obj is not iterable
What’s happening
  1. arr.extra = "oops" adds an ordinary property to the array object. It doesn't change length (still 3) because "extra" isn't an index.
  2. 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.
  3. 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.
  4. 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.)
  5. 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.)

#Unique values with Set

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
function uniqueArray(array) {
  return [...new Set(array)]; // or Array.from(new Set(array))
}
uniqueArray([1, 1, 2, 3, 3]); // [1, 2, 3]
What’s happening
  1. 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.
  2. Sets remember insertion order, and a duplicate doesn't move the original. So the first occurrence of each value decides its position.
  3. [...set] spreads the Set back into a new array using its iterator → [1, 2, 3]. Array.from(set) does exactly the same.
  4. 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 = new Set(["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 = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.union(b);        // {1, 2, 3, 4}
a.intersection(b); // {2, 3}
a.difference(b);   // {1}

// Older equivalent
const intersection = new Set([...a].filter((x) => b.has(x)));
What’s happening
  1. 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).
  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.
  3. 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.
  4. 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.
  5. 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.
AdvancedDeduping objects by key

#Map deep dive

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 = new Map([
  ["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 objects
const meta = new Map();
const btn = document.querySelector("button");
meta.set(btn, { clicks: 0 });
meta.get(btn).clicks++;
What’s happening
  1. 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.
  2. 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.
  3. person.delete("age") removes that entry and returns true. Now the Map holds name and occupation, so size is 2.
  4. 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.
  5. 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
MapPlain object
Key typesAnythingStrings and symbols only
OrderInsertion orderMostly insertion (integer keys first)
Sizemap.sizeObject.keys(obj).length
Iterable directlyYesNo (use Object.entries)
Inherited keysNoneHas a prototype (toString, …) unless Object.create(null)
Frequent add/removeOptimised for itSlower
JSON supportNo — convert firstYes

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 ways
const obj = Object.fromEntries(person);
const map = new Map(Object.entries(obj));
JSON.stringify([...person]); // serialise as an array of pairs
What’s happening
  1. 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().
  2. 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.
  3. 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.
  4. 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.
  5. 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.

#WeakMap and WeakSet

Added

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 leaks
const privateData = new WeakMap();

class User {
  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 does
const cache = new WeakMap();
function expensive(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 twice
const processed = new WeakSet();
function process(node) {
  if (processed.has(node)) return;
  processed.add(node);
}
What’s happening
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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).

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.