JavaScript & TypeScript Fundamentals
JavaScript & TypeScript Fundamentals

Maps, Sets, objects & arrays: choosing the collection

Full · ~30 min
After this unit you can
  • Choose a Set or Map over includes/find inside 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; Map keys keep their type and compare by identity
  • Spot plain-object dictionary traps — inherited keys like "constructor", a Map serialising to {} — and fix them
  • Write the everyday moves: dedupe with new Set, index with new Map(list.map(…)), count, convert, and group with Object.groupBy/Map.groupBy (ES2024)

Read

Simple
1 / 2

Four containers, four questions

Flag

Each built-in collection is good at answering one question:

The question you keep askingReach forThe call
"What's at position 3? What comes next?"arraylist[3], for…of, .map()
"Is X in here?"Setset.has(x)
"What's stored under key K?"Mapmap.get(k)
"What is this record's name?" (fields known up front)objectuser.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.

Medium
1 / 2

Index, dedupe, count — the everyday moves

Flag

An 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])) — the Map constructor takes an iterable of [key, value] pairs. Building it is one pass over customers; after that, joining 10,000 orders costs 10,000 gets, not 10,000 scans of the customer list (customers.find(…) inside orders.map is the slow version).
  • ?.name ?? "(unknown)" — get returns undefined for a missing key, so an order whose customer was deleted gets a placeholder instead of a crash.
  • [...new Set(…)] — a Set drops repeats as you add them, and the spread turns it back into an array. Array.from(set) does the same.
  • The Map dedupe — new Set(orders) would keep both copies of order 7 when two pages of results overlap: they are different objects, and a Set compares objects by identity. Keying a Map by id collapses them. When a key repeats, set replaces 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, get returns undefined, so start from 0. (|| 0 works 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.

Complex
1 / 2

When a plain object is the wrong dictionary

Flag

A 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?

Flag

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?

Flag

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?

Flag

cache.set({ page: 1 }, rows), and later cache.get({ page: 1 }), where cache is a Map. What does the get return, and why?

Flag

fetch(url, { method: "POST", body: JSON.stringify({ totals }) }), where totals is a Map with two entries. What does the server receive?

Flag

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?

Flag

You want orders grouped by their customer object (not its id). Which call, and why?

Flag

Fill in the blank

Turn the list of tags into a list with each tag once, keeping first-seen order.

Flag
const uniqueTags = [...new (tags)];

Index customers by id once, then look each order's customer up in constant time on average.

Flag
const 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.

Flag
const 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.

Flag
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).

Flag
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));
Flag
const counts = {};
for (const w of ["to", "constructor", "to"]) {
  counts[w] = (counts[w] || 0) + 1;
}
console.log(counts.to, counts.constructor);
Flag
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]));
Flag

Challenge

Pick the right collection for the orders screen

Flag

orders.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:

  1. attachCustomers joins orders to customers with customers.find(…) inside orders.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.
  2. dedupeOrders uses new Set(orders). Infinite scroll fetched two pages that overlap, so order 2 arrives twice — as two separate objects with the same id.
  3. countTags counts into a plain object. Staff type tags freely, and someone typed constructor.
  4. saveCustomerIndex / loadCustomerIndex put a Map through JSON for localStorage. 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:

  • attachCustomers attaches each order's customer name, and "(unknown)" when the customer is missing
  • attachCustomers reads each customer about once in total, not once per order
  • dedupeOrders keeps one order per id (first-seen order) when two copies are separate objects
  • countTags counts every tag correctly, including "constructor", "toString" and "__proto__"
  • A saved index loads back with every entry, and get(7) still finds customer 7

Loading the in-browser editor…

Further reading