Chapter 18
Maps: Looking Things Up by Name
Map, and the trap of using a plain object instead.
An array is a numbered row. That’s perfect when position means something, and useless when it doesn’t. Counting how often each word appears in a document isn’t a numbered anything, it’s a pile of names with numbers attached.
A Map
const counts = new Map<string, number>();
counts.set("apple", 3);
counts.set("pear", 1);
console.log(counts.size);
console.log(counts.get("apple"));
console.log(counts.has("pear"));
console.log(counts.has("fig"));
2
3
true
false
new Map<string, number>() builds an empty one. Those angle brackets are
chapter 10’s Array<number> again: Map on its own is “a lookup table of
something to something”, and <string, number> fills both in. Chapter
27 explains the machinery.
Four methods cover most of it:
| Method | Does |
|---|---|
.set(key, value) | store, replacing any existing value for that key |
.get(key) | fetch, or undefined |
.has(key) | is that key present |
.delete(key) | remove it |
Plus .size, which is a property like an array’s .length.
get, and a pattern you’ve now seen three times
const counts = new Map<string, number>();
const n = counts.get("apple");
console.log(n + 1);
error TS18048: 'n' is possibly 'undefined'.
.get() is declared as returning number | undefined, because a key you
never set genuinely has no value. So TypeScript makes you deal with that
before using the result.
That’s the third time: chapter 14’s .find(), chapter 15’s deal(), and
now .get(). It’s worth naming as a habit rather than three coincidences.
When a function might not have an answer, its return type should say so,
and then everyone who calls it gets told by the compiler instead of by a
crash. Chapter 10’s array indexing is the odd one out, and chapter 19 is
about closing that gap.
Walking through one
const counts = new Map<string, number>();
counts.set("apple", 3);
counts.set("pear", 1);
for (const [word, count] of counts) {
console.log(word, count);
}
apple 3
pear 1
for...of works, and each item is a two-part [key, value]. Pulling both
out on the left like that is destructuring, and it saves you writing
item[0] and item[1].
A Map remembers insertion order, so that loop visits keys in the order
you set them. That’s a real guarantee you can rely on.
.keys() and .values() give you one side each, and spreading them into
an array makes them easy to work with:
console.log([...counts.keys()]);
console.log([...counts.values()]);
[ 'apple', 'pear' ]
[ 3, 1 ]
Which means chapter 14’s whole toolkit applies. [...counts.values()]
followed by .reduce() totals them, and [...counts.keys()].sort() puts
the names in order.
Counting things
The idiom this chapter exists for:
const words = ["apple", "pear", "apple", "fig", "apple"];
const counts = new Map<string, number>();
for (const word of words) {
const current = counts.get(word);
if (current === undefined) {
counts.set(word, 1);
} else {
counts.set(word, current + 1);
}
}
console.log(counts);
Map(3) { 'apple' => 3, 'pear' => 1, 'fig' => 1 }
Seen it once, you’ll write it forever: look up, and either start at 1 or add
to what’s there. The undefined check isn’t defensive padding, it’s the
difference between the first time you see a word and every time after.
Why not just use an object?
You might reasonably ask. Chapter 12’s objects already look up values by
name, and counts["apple"] is less typing than counts.get("apple").
People do use objects this way, and it mostly works, and the ways it fails are nasty enough to be worth ten minutes.
An object is never empty.
const counts: Record<string, number> = {};
console.log(counts["toString"]);
console.log("constructor" in counts);
[Function: toString]
true
That object has nothing in it, and it answered two lookups. Every JavaScript object inherits a handful of properties, and they’re reachable by name exactly like the ones you put there.
Here’s what that does to the counting loop above:
const words = ["apple", "toString", "apple"];
With an object, console.log gives you this:
{ apple: 2, toString: 'function toString() { [native code] }1' }
With a Map, the same words give you this:
Map(2) { 'apple' => 2, 'toString' => 1 }
The object version found an existing value for "toString", decided this
wasn’t the first sighting, and added 1 to a function, which JavaScript
was happy to do by turning it into text first.
Nothing crashed. Nobody was warned. There is now a string in your number column, and it only happens on documents containing certain words.
So: use a Map when the keys are data. Words from a document, IDs from
a file, anything a user or another program supplies. Use an object when the
keys are a fixed set you wrote yourself, which is what chapter 12’s Book
is, three known properties you named on purpose.
The dividing line is whether you knew the keys when you wrote the code.
Exercise 1 · Break the object version
Write the counting loop twice, once with a Map and once with a
Record<string, number>, and run both over
["a", "toString", "a", "valueOf"].
Print both results side by side. The Map gives you three counts. The
object gives you something you would not want in a report.
Then try it with ["a", "b", "a"] and watch both versions agree
perfectly, which is precisely why this bug survives so long in real code.
Check yourself
Project
Word frequency over a real file
Roughly 50 minutes
Count how often each word appears in a text file. This needs chapter 17 to read the file and this chapter to do the counting, which is the point: it’s the first project that genuinely needs two chapters at once.
Get a file to work on. Any plain text will do. Save a few paragraphs
as text.txt, or reuse something you already have.
The steps:
- Read the file with
fs.readFileSync, guarded byexistsSyncso a missing file gives a clear message instead ofENOENT. - Split it into words.
.split(/\s+/)splits on any run of whitespace, which handles the newlines and double spaces that.split(" ")gets wrong. - Clean each word:
.toLowerCase()from chapter 4, soTheandthecount together. Skip empty strings, chapter 8’s phantom problem again. - Count them into a
Map<string, number>with the idiom above. - Report the ten most common, highest first.
Step 5 is chapter 14’s work: [...counts] gives you an array of
[word, count] pairs, which you can .sort() with a comparator on the
second element and then .slice(0, 10).
the: 142
and: 87
of: 81Print the totals and check them. Print how many words you counted and
how many distinct words there were. The first should equal the number of
words you split out, and chapter 14’s .reduce() over the values is how
you confirm it. A total that doesn’t match means you dropped something in
step 3.
Then do the thing this chapter is about. Once it works, write the
counting step a second time using Record<string, number> instead of a
Map, and run both over the same file.
On ordinary prose they’ll agree exactly. Now add a line to text.txt
containing the words toString, valueOf and constructor, and run it
again. One version reports three new words. The other reports something
you’d have to see to believe.
Stretch: ignore common words like “the” and “and” by keeping a list
of them, and use chapter 14’s .filter() to drop them before counting.
Then ask yourself where that list should live: an array you search every
time, or a Map you look up in. For a few dozen words it genuinely does
not matter, and knowing that it does not matter is worth as much as
knowing when it would.