Maps, Sets, objects & arrays: choosing the collection
- Choose a
SetorMapoverincludes/findinside a loop, and explain why a scan per item is n × m work - Predict key behaviour: object keys become strings and integer-like keys sort first;
Mapkeys keep their type and compare by identity - Spot plain-object dictionary traps — inherited keys like
"constructor", aMapserialising to{}— and fix them - Write the everyday moves: dedupe with
new Set, index withnew Map(list.map(…)), count, convert, and group withObject.groupBy/Map.groupBy(ES2024)
Read
Four containers, four questions
FlagEach built-in collection is good at answering one question:
| The question you keep asking | Reach for | The call |
|---|---|---|
| "What's at position 3? What comes next?" | array | list[3], for…of, .map() |
| "Is X in here?" | Set | set.has(x) |
| "What's stored under key K?" | Map | map.get(k) |
"What is this record's name?" (fields known up front) | object | user.name |
The difference that bites first is cost. array.includes(x) and array.find(…) walk the
array from the front until something matches — and on a miss they check every element.
set.has(x) and map.get(k) don't scan. The ECMAScript spec only promises access times that are,
on average, "sublinear on the number of elements" (better than a scan, not necessarily constant).
V8, the engine in Chrome and Node, builds Map and Set on hash tables, so there a lookup stays
about as fast as the collection grows.
// 10,000 events × up to 5,000 comparisons each
events.filter((e) => !blockedIds.includes(e.userId));
// Build the Set once (one pass), then 10,000 events × 1 lookup each
const blocked = new Set(blockedIds);
events.filter((e) => !blocked.has(e.userId));
One includes on a short array is fine. The bug is the loop around it: a scan inside a loop
is n × m work, so doubling both lists quadruples the time. When you see includes, find or
indexOf inside map, filter or a for, build a Set or Map once, outside the loop.
Index, dedupe, count — the everyday moves
FlagAn orders screen gets two lists from the API and has to join, clean and summarise them:
type Customer = { id: number; name: string };
type Order = { id: number; customerId: number; status: "open" | "shipped"; tags: string[] };
// 1. Index once: id → customer. One pass, then every lookup is a get.
const customersById = new Map(customers.map((c) => [c.id, c]));
const rows = orders.map((o) => ({
...o,
customerName: customersById.get(o.customerId)?.name ?? "(unknown)",
}));
// 2. Dedupe primitives with a Set. First-seen order is kept.
const allTags = [...new Set(orders.flatMap((o) => o.tags))];
// 3. Dedupe objects by a key, not by identity.
const uniqueOrders = [...new Map(orders.map((o) => [o.id, o])).values()];
// 4. Count with a Map.
const tagCounts = new Map<string, number>();
for (const tag of orders.flatMap((o) => o.tags)) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
What each line is doing:
new Map(customers.map((c) => [c.id, c]))— theMapconstructor takes an iterable of[key, value]pairs. Building it is one pass overcustomers; after that, joining 10,000 orders costs 10,000gets, not 10,000 scans of the customer list (customers.find(…)insideorders.mapis the slow version).?.name ?? "(unknown)"—getreturnsundefinedfor a missing key, so an order whose customer was deleted gets a placeholder instead of a crash.[...new Set(…)]— aSetdrops repeats as you add them, and the spread turns it back into an array.Array.from(set)does the same.- The
Mapdedupe —new Set(orders)would keep both copies of order 7 when two pages of results overlap: they are different objects, and aSetcompares objects by identity. Keying aMapbyidcollapses them. When a key repeats,setreplaces the value but the key keeps its first position, so this keeps the last copy in the first copy's place. tagCounts.get(tag) ?? 0— the first time a tag is seen,getreturnsundefined, so start from 0. (|| 0works here too, because a stored count is never 0;??says what you mean.)
To count entries, read map.size or set.size — a property, not a method.
Object.keys(obj).length is the object equivalent, and it builds a whole array of keys each time
you ask.
When a plain object is the wrong dictionary
FlagA plain object works as a lookup table until the keys stop being yours. Three traps, all real.
1. Every object already has keys
{} inherits from Object.prototype, so it already "contains" constructor, toString,
hasOwnProperty and friends. Count words from user text with an object and one of them will
eventually be "constructor":
const counts = {};
for (const w of ["to", "constructor", "to"]) {
counts[w] = (counts[w] || 0) + 1;
}
counts.to; // 2
counts.constructor; // "function Object() { [native code] }1"
counts["constructor"] was the inherited Object function — truthy — so || 0 never kicked in,
and function + 1 is string concatenation. Switching to ?? 0 doesn't help (a function isn't
nullish), and neither does w in counts, which also sees inherited keys.
"__proto__" is worse: assigning to it calls a setter that swaps the object's prototype (or
silently ignores a non-object value), and with keys an attacker controls this is the doorway to
prototype pollution. MDN's Map page lists it as the security difference: a Map is safe with
user-provided keys.
Fixes, best first: use a Map; or create the object with Object.create(null) (no prototype, no
inherited keys — this is what Object.groupBy returns); or check Object.hasOwn(obj, key)
(ES2022) before reading. hasOwn alone still can't store a "__proto__" key.
2. Integer-like keys jump the queue
An object's keys are ordered, but not simply. Keys that look like array indexes come first, in ascending numeric order; other strings follow in the order they were added:
Object.keys({ high: 3, 10: "x", low: 1, 2: "y" }); // ["2", "10", "high", "low"]
An object keyed by numeric id therefore iterates in id order, not the order you inserted —
sort by date, index by id, and the dates come back scrambled. Map iterates in insertion order,
always. MDN's advice for objects is not to rely on property order at all.
3. The keys come back as strings
Object.keys, Object.entries, for…in and Object.groupBy all hand you string keys, even if
you wrote numbers. TypeScript knows: Object.keys returns string[], so
Object.keys(byId).includes(7) is a compile error, and the honest conversion is
Object.keys(byId).map(Number). A plain as number[] is rejected (the types don't overlap);
as unknown as number[] compiles, and is wrong at runtime.
Quiz
For each of 10,000 incoming events you check whether event.userId is one of 5,000 blocked ids. What should blocked be?
A signup form checks once per submit that the chosen country code is in a 250-item array. A reviewer asks you to turn the array into a Set. Is it worth it?
In plain JavaScript, users are indexed by numeric id in a plain object (byId[user.id] = user). Then for (const id in byId) if (id === selectedId) … never matches, though selectedId is 7 and user 7 is there. Why?
cache.set({ page: 1 }, rows), and later cache.get({ page: 1 }), where cache is a Map. What does the get return, and why?
fetch(url, { method: "POST", body: JSON.stringify({ totals }) }), where totals is a Map with two entries. What does the server receive?
A word counter does counts[w] = (counts[w] || 0) + 1 on const counts = {}. For the word "constructor" it stores "function Object() { [native code] }1". Which change fixes it for every possible word?
You want orders grouped by their customer object (not its id). Which call, and why?
Fill in the blank
Turn the list of tags into a list with each tag once, keeping first-seen order.
Flagconst uniqueTags = [...new (tags)];
Index customers by id once, then look each order's customer up in constant time on average.
Flagconst customersById = new Map(customers.map((c) => [c., c])); const name = customersById.(order.customerId)?.name;
Count how often each word appears, starting from 0 the first time a word is seen.
Flagconst counts = new Map<string, number>(); for (const w of words) counts.(w, (counts.get(w) 0) + 1);
Save customersById, a Map<number, Customer>, to localStorage and load it back with numeric keys intact.
localStorage.setItem("customers", JSON.stringify()); const restored = new (JSON.parse(localStorage.getItem("customers") ?? "[]"));
Group orders by the customer object itself, keeping object keys as objects (ES2024).
const byCustomer = .groupBy(orders, (o) => o.customer);
Predict the output
const priority = { high: 3, 10: "x", low: 1, 2: "y" };
console.log(Object.keys(priority));const counts = {};
for (const w of ["to", "constructor", "to"]) {
counts[w] = (counts[w] || 0) + 1;
}
console.log(counts.to, counts.constructor);const m = new Map();
m.set(1, "number").set("1", "string").set(NaN, "nan");
console.log(m.size, m.get(1), m.get(NaN), m.get([1]));Challenge
Pick the right collection for the orders screen
Flagorders.ts holds four helpers for an orders screen. Each one returns the right answer on a small,
friendly input, which is why they shipped. Each one also has a bug that only shows up on real
data:
attachCustomersjoins orders to customers withcustomers.find(…)insideorders.map(…). With 5,000 customers and 10,000 orders that is up to 50 million comparisons. The test counts how many times your code reads a customer out of the array.dedupeOrdersusesnew Set(orders). Infinite scroll fetched two pages that overlap, so order 2 arrives twice — as two separate objects with the sameid.countTagscounts into a plain object. Staff type tags freely, and someone typedconstructor.saveCustomerIndex/loadCustomerIndexput aMapthrough JSON forlocalStorage. Open the preview and look at what was saved.
The types (Customer, Order, OrderRow) are in types.ts.
Your task
Edit orders.ts so the tests pass:
attachCustomersattaches each order's customer name, and"(unknown)"when the customer is missingattachCustomersreads each customer about once in total, not once per orderdedupeOrderskeeps one order perid(first-seen order) when two copies are separate objectscountTagscounts every tag correctly, including"constructor","toString"and"__proto__"- A saved index loads back with every entry, and
get(7)still finds customer 7