Working with Collections
map, filter and reduce and the types that flow through them; Map and Set for lookups and uniqueness; and why a plain object is a poor dictionary.
map, filter and reduce and the types that flow through them; Map and Set for lookups and uniqueness; and why a plain object is a poor dictionary.
You have four hundred thousand photos and a list of the ones
already processed. For each photo you check whether it is in
that list. The check is one line, it looks free, and the job
takes forty minutes because includes scans from the beginning
every single time.
An array files things by position. When you want to find things by name, or only ever ask "have I seen this?", position is the wrong handle. By the end of this lesson you will have the two collections built for those jobs, know why both are fast, and know when a plain object is the better answer after all.
const sizes = new Map<string, number>();
sizes.set("dawn.jpg", 1450);
sizes.set("tram.jpg", 320);
sizes.get("dawn.jpg"); // 1450
sizes.get("missing.jpg"); // undefined
sizes.has("tram.jpg"); // true
sizes.delete("tram.jpg"); // true if it was there
sizes.size; // a property, not a methodMap<string, number> says the keys are strings and the values
are numbers. get returns number | undefined — the checker
knows a lookup can fail, which is exactly right and is the first
advantage over a plain object.
Building one from pairs, and walking it:
const sizes = new Map([
["dawn.jpg", 1450],
["tram.jpg", 320],
]);
for (const [name, size] of sizes) {
console.log(`${name}: ${size}`);
}
[...sizes.keys()]; // the names
[...sizes.values()]; // the sizes
[...sizes.entries()]; // the pairsA Map remembers insertion order, so iteration comes back in
the order you added things.
const processed = new Set<string>();
processed.add("dawn.jpg");
processed.add("dawn.jpg"); // no effect - already there
processed.has("dawn.jpg"); // true
processed.delete("dawn.jpg");
processed.size;A Set holds unique values and answers one question well: is this in here? Two jobs come up constantly.
Removing duplicates, in one line:
const unique = [...new Set(allTags)];The check from the opening, made fast:
const processed = new Set(processedNames);
for (const photo of photos) {
if (processed.has(photo.name)) continue;
process(photo);
}has on a Set takes about the same time whatever the size.
includes on an array scans. Inside a loop over four hundred
thousand photos, checking a list of four hundred thousand names
is billions of comparisons; checking a set is four hundred
thousand lookups. Same line of code, a different collection, and
forty minutes becomes seconds.
That is the whole reason both types exist: they find things by computing where a key lives rather than by looking through everything.
An object also maps keys to values, and for a fixed set of named fields it is the right choice — that is what the objects lesson was about. The distinction is whether the keys are fields or data.
Bad — an object used as a lookup built at runtime.
const counts: Record<string, number> = {};
for (const tag of tags) {
counts[tag] = (counts[tag] ?? 0) + 1;
}
counts["constructor"]; // not a number - an inherited function
counts["toString"]; // likewiseGood — a Map.
const counts = new Map<string, number>();
for (const tag of tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
counts.get("constructor"); // undefined, as it should beThe failure is worth following through, because nothing along the way reports anything.
An object inherits properties
constructor, toString and valueOf appear to exist on
every object, and they are functions.
So the lookup returns a function
counts["constructor"] is not undefined and not a number.
The ?? 0 never fires, because the value is not nullish.
Arithmetic on it gives NaN
Which then spreads through everything downstream, exactly as the numbers lesson described.
And the checker was satisfied throughout
You declared Record<string, number>, so it believed every
value was a number. A Map has no inherited keys, and
get is honestly typed number | undefined.
A Map also takes any value as a key rather than only strings,
reports its size directly, and keeps insertion order for every
key type. Use an object when the keys are known field names; use
a Map when they are data.
Collecting things into buckets comes up constantly:
const byCity = new Map<string, Photo[]>();
for (const photo of photos) {
const existing = byCity.get(photo.city) ?? [];
existing.push(photo);
byCity.set(photo.city, existing);
}Read the middle as "whatever is there, or a new empty list".
Without the ??, the first photo for each city would fail.
Newer runtimes have this built in:
const byCity = Map.groupBy(photos, (photo) => photo.city);Once grouped, a nested loop becomes two flat ones — the performance shape from the loops lesson:
for (const city of cities) {
const cityPhotos = byCity.get(city) ?? [];
render(city, cityPhotos);
}const map = new Map(Object.entries(plainObject));
const object = Object.fromEntries(map);
const array = [...map.entries()];
const set = new Set(array.map((p) => p.name));
const backToArray = [...set];The spread turns any of these into an array, which is where the
map/filter/reduce methods live. A Map and a Set do not
have them, so the usual shape is to spread, transform, and build
a new collection:
const largeNames = new Set(
[...sizes.entries()]
.filter(([, size]) => size > 1000)
.map(([name]) => name),
);JSON.stringify does not handle either — a Map serialises
as {}. Convert with Object.fromEntries or an array of pairs
before sending anything over a network or writing it to a file.
One question settles it almost every time: how will you find things again?
A fixed set of named fields. Typos are caught.
Tags, filenames, ids. Nothing inherited, any key type.
You are describing options, not storing data.
Membership and uniqueness. No values at all.
And the performance rule worth carrying: any includes or
find inside a loop is a scan inside a scan. If the collection
being searched does not change, build a Set or Map from it
once, before the loop.
MAP - lookup by a key that is data
new Map<string, number>()
m.set(k, v) m.get(k) m.has(k) m.delete(k) m.size
get returns V | undefined - the checker knows it can fail
for (const [k, v] of m)
[...m.keys()] [...m.values()] [...m.entries()]
keeps insertion order; any value as a key, matched by identity
SET - membership and uniqueness
new Set<string>()
s.add(x) s.has(x) s.delete(x) s.size
[...new Set(items)] remove duplicates
WHY THEY EXIST
has/get is about the same speed at any size
array includes/find SCANS from the start
a scan inside a loop is the classic slow path:
build a Set once, before the loop
MAP VS OBJECT
object keys are known FIELD NAMES
Map keys are DATA (tags, filenames, ids)
an object inherits constructor/toString/valueOf, so a
user-supplied key can return a function where you expected
a number - and the declared type says otherwise
object keys are always strings: obj[1] is obj["1"]
GROUPING
const existing = byCity.get(city) ?? [];
Map.groupBy(items, fn) where available
CONVERTING
new Map(Object.entries(obj)) Object.fromEntries(map)
[...map] [...set] to get map/filter/reduce
JSON.stringify(map) is "{}" convert before serialising
CHOOSING - how will you find it again?
by position -> array known field -> object
by data key -> Map "seen it?" -> SetYou now have all four everyday collections and a way to choose
between them, plus the reason Map and Set are fast — which
is the same reason their keys behave the way they do.
Next is Classes and Objects, the last major piece of syntax in this course. It covers building types that carry data and behaviour together, and — as with several lessons here — the cases where a plain function and an object type are the better answer.
Before you move on, take a loop containing an includes or a
find and rewrite it with a Set or Map built beforehand.
Then time both on a large input. The gap is larger than you
expect, and seeing it once changes how you read every loop
afterwards.