Contents

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:

MethodDoes
.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

1. const n = someMap.get("x"); console.log(n + 1); Why does this not compile?

Not quite. It returns the value type you declared. The problem is the other possibility.

Yes. TS18048. The same honesty as .find() in chapter 14 and deal() in chapter 15: a function that might have no answer says so in its return type.

Not quite. TypeScript has no idea what is in the map at runtime. It objects to the type regardless of contents.

2. You use a plain object to count words and one of the words is "toString". What happens?

Not quite. It is an ordinary string, and the object already has a property by that name that you never set.

Yes. Verified output: a string reading "function toString() { [native code] }1". Nothing crashes and nothing warns.

Not quite. Nothing is rejected. That is what makes this dangerous rather than merely annoying.

3. When is a plain object the right choice over a Map?

Yes. Chapter 12's Book is exactly this: three properties you named on purpose. The dividing line is whether you knew the keys when you wrote the code.

Not quite. The number of keys is not the issue. Three words taken from a document are still data.

Not quite. An object is the natural shape for a known set of named fields, and every type in this book since chapter 12 has been one.

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:

  1. Read the file with fs.readFileSync, guarded by existsSync so a missing file gives a clear message instead of ENOENT.
  2. Split it into words. .split(/\s+/) splits on any run of whitespace, which handles the newlines and double spaces that .split(" ") gets wrong.
  3. Clean each word: .toLowerCase() from chapter 4, so The and the count together. Skip empty strings, chapter 8’s phantom problem again.
  4. Count them into a Map<string, number> with the idiom above.
  5. 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: 81

Print 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.