Before you begin
Why this matters
Counting, grouping, and looking things up by name are among the most common jobs in a program. Each depends on what a lookup gives for a name that is not there yet, and on the order the names come back in. Knowing both lets you predict a wrong count before it is printed.
What this helps you reason about
- Count things by name without getting NaN or a strange count.
- Predict the order in which a Map and a plain object list their keys.
- Explain how a map finds a key quickly, and why a key built from changing data goes stale.
A meadow survey counts the flowers seen on a walk: 4 daisies, 9 clover, 3 poppies. The program looks up each count by the flower’s name, not by a position. This lesson shows how a TypeScript Map and a plain object keep such counts, what a missing name gives, and in which order the names come back.
Already know this? The short version
- new Map<K, V>() with set, get, has, delete, and size; get gives undefined for a missing key, so default with ?? and test with has.
- A plain object used as a map inherits keys from Object.prototype: counts["constructor"] is a function before anything is stored. Use a Map, or Object.create(null) with Object.hasOwn.
- Map compares keys with SameValueZero: strings and numbers by value, objects by identity. V8 implements it as a hash table.
- A Map lists keys in insertion order; a plain object lists integer-like keys first, ascending, then other strings in insertion order.
- A string key built from an object does not follow later changes to the object; to change a key, delete the entry and set it again.
Words in this lesson (7)
- Set and Map
- Built-in collections: a Set holds each value at most once, and a Map stores a value under a key. Both compare object keys by identity, and numbers and strings by value.
- undefined
- A special JavaScript value that means “no value yet”. A let variable declared without a value holds undefined.
- prototype
- An object that another object inherits properties from. Every plain object inherits from Object.prototype, so it already has properties such as constructor and toString.
- hash
- A number computed from a key. A hash map uses it to choose the bucket where the key is stored, so equal keys must give equal hashes.
- bucket
- One of the numbered places in a hash map’s table. A key’s hash picks its bucket, and a lookup searches only that bucket.
- collision
- Two different keys whose hashes pick the same bucket. The bucket keeps both, and a lookup compares the keys for equality to find the right one.
- insertion order
- The order in which keys were first added to a map. Storing a new value under a key that is already there does not change it.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Store and find
How do you store, find, replace, and remove a value by its key?
A map stores values under keys. Each key appears at most once and leads to one value: the key "daisy" leads to the count 4. A lookup asks for a key and gets its value back, without walking through the entries one by one.
new Map<string, number>() makes an empty Map whose keys are strings and whose values are numbers. set(key, value) stores a value. When the key is already there, set replaces its value, and the Map still holds one entry for that key. get(key) reads the value, has(key) asks whether the key is there, delete(key) removes the entry, and size counts the entries.
const meadow = new Map<string, number>();
meadow.set("daisy", 4);
// replaces 4
meadow.set("daisy", 6);
console.log(meadow.get("daisy"), meadow.size); // 6 1
meadow.delete("daisy");
console.log(meadow.has("daisy")); // false A map keeps one value per key; storing under a key that is already there replaces the value.
Coming from Python: A Map is close to a dict, but you call get and set. meadow["daisy"] = 4 does not store an entry: it sets a property on the Map object, and get("daisy") still gives undefined.
Precisely: the type of get
get returns number | undefined for this Map. The compiler knows that a key may be missing, so code that uses the result as a number must handle undefined first.
Experiment
Store, replace, and remove a count
set on a key that is already there replaces the value. get on a key that is not there gives undefined. size counts entries, one per key.
Why it matters A map keeps at most one entry per key. Storing under a key that is already there replaces its value; it does not add a second entry.
Predict the three printed values. Then remove the line meadow.delete("clover"); and predict again.
const meadow = new Map<string, number>();
meadow.set("daisy", 4);
meadow.set("clover", 9);
meadow.set("daisy", 6);
meadow.delete("clover");
console.log(meadow.get("daisy"), meadow.get("clover"), meadow.size);
What to look for
Step past each line. meadow shows Map(0) {}, then Map(1) {"daisy" => 4}, then Map(2) {"daisy" => 4, "clover" => 9}. After the second set for daisy it still shows two entries, with "daisy" => 6. After the delete it shows Map(1) {"daisy" => 6}. The run prints 6 undefined 1. Node sometimes stops on one line twice; that is normal for this debugger. After the edit, clover stays, and the run prints 6 9 2.
Expected output
6 undefined 102 / Missing keys
What does a lookup give for a key that is not there?
get gives undefined for a key that is not in the Map. That matters most when counting. counts.get(name) + 1 gives NaN for a name seen for the first time, because undefined + 1 is NaN. Give a missing key a starting value instead: (counts.get(name) ?? 0) + 1 uses 0 when get gives undefined. As Conditions and truthiness shows, ?? keeps a stored 0, where || would replace it.
To tell a missing key from a stored value, ask has(key). A missing key and a key stored with the value undefined both make get give undefined.
Older code uses a plain object as a map. Record<string, number> is the type of an object whose property names can be any string, each holding a number. Such an object is not empty, though. It inherits properties from its prototype, an object it takes properties from. So counts["constructor"] is a function before anything is stored, and a name such as "constructor" or "toString" is counted wrong.
const counts = new Map<string, number>();
for (const name of ["daisy", "clover", "daisy"]) {
counts.set(name, (counts.get(name) ?? 0) + 1);
}
console.log(counts.get("daisy"), counts.get("poppy")); // 2 undefined
console.log(counts.has("poppy")); // false A missing key gives undefined: give it a starting value with ??, and ask has to tell missing from stored.
Coming from Python: There is no KeyError: a missing key gives undefined, and the program continues with it.
Precisely: objects without a prototype
Object.create(null) makes an object with no prototype, so it starts with no keys. Object.hasOwn(obj, key) asks whether the object itself has the key, ignoring inherited ones. Writing a key such as "__proto__" to a plain object changes its prototype instead of storing an entry, so keys that come from users belong in a Map.
Experiment
Count names in a plain object
counts["constructor"] is not missing in an empty object: it is inherited from Object.prototype, and it is a function. || keeps it, because a function is truthy, and + 1 turns it into text and adds "1".
Why it matters A plain object is not an empty map. For keys that come from data, use a Map.
Predict what is printed. Then change "clover" to "constructor" in the list and predict again.
const counts: Record<string, number> = {};
for (const name of ["daisy", "clover", "daisy"]) {
counts[name] = (counts[name] || 0) + 1;
}
console.log(counts);
What to look for
Step through the loop. counts shows {}, then {daisy: 1}, then {daisy: 1, clover: 1}, then {daisy: 2, clover: 1}. name shows “not initialized yet” at the stops on the for line, between rounds. The run prints { daisy: 2, clover: 1 }. After the edit, counts shows constructor: "function Object() { [native code] }1" after the second round, and the run prints that text as the count.
Expected output
{ daisy: 2, clover: 1 }03 / Hashing
How does a hash map find a key?
A lookup does not compare the key with every stored key. Picture a hash map, the usual way to build a map: a table of numbered buckets. A hash function turns the key into a number, its hash, and the hash picks the bucket: hash % 4 picks one of 4 buckets. A lookup computes the hash of the key it is given and searches that one bucket. So finding a key takes about the same time with 10 entries as with 10,000.
Two rules follow. Equal keys must give equal hashes, or a lookup searches the wrong bucket. And different keys can land in the same bucket: a collision. The bucket keeps both, and the lookup compares the keys for equality to find the right one.
A Map follows these rules for you. It compares keys the way Equality and comparison describes, strings and numbers by value and objects by identity, and its hash agrees with that comparison. The function below is a toy hash: it adds the character codes, so it gives "clover" and "fern" the same bucket. charCodeAt(0) gives the code of a one-character string, and for (const ch of key) visits each character.
function hash(key: string): number {
let sum = 0;
for (const ch of key) {
sum = sum + ch.charCodeAt(0);
}
return sum;
}
// prints 2 3 3: clover and fern share bucket 3
console.log(hash("daisy") % 4, hash("clover") % 4, hash("fern") % 4); A hash picks one bucket to search; equal keys must give equal hashes, and colliding keys share a bucket.
Precisely: what the specification asks
The ECMAScript specification does not require hashing. It requires a Map to find a key in less than linear time on average, and V8, the engine in Node.js, uses a hash table. A real hash function mixes the bits far better than a sum, so that keys spread evenly over the buckets: "listen" and "silent" have the same sum.
04 / Order
In what order does a loop visit the keys?
A Map lists its keys in insertion order: the order in which each key was first set. Setting a key that is already there keeps its place. Deleting it and setting it again moves it to the end. A for (const [name, count] of meadow) loop, which gives each entry as a key and value pair, uses this order, and so do keys() and the debugger.
A plain object has a different rule. Keys that look like whole numbers, such as "3" and "12", come first, in ascending order. Other strings follow, in insertion order. Object.keys(obj) returns the keys in that order.
const meadow = new Map<string, number>();
meadow.set("poppy", 3);
meadow.set("daisy", 4);
// poppy keeps its place
meadow.set("poppy", 5);
for (const [name, count] of meadow) {
console.log(name, count); // poppy 5, then daisy 4
} A Map keeps insertion order; a plain object puts number-like keys first.
Coming from Python: Like a dict since Python 3.7, a Map keeps insertion order. A plain object does not, for keys that look like numbers.
Coming from Java: A Map behaves like a LinkedHashMap, not like a HashMap: its loops follow insertion order.
Experiment
List the keys of a plain object
A plain object lists keys that look like whole numbers first, in ascending order, and other strings after them, in the order they were added. A Map would keep north, 12, 3: the order of the set calls.
Why it matters Do not rely on the key order of a plain object whose keys can look like numbers. A Map keeps insertion order for every key.
Predict the order of the printed keys. Then add the line squares["1"] = 4; after squares["3"] = 2; and predict again.
const squares: Record<string, number> = {};
squares["north"] = 7;
squares["12"] = 5;
squares["3"] = 2;
console.log(Object.keys(squares));
What to look for
Step past each line. squares shows {north: 7}, then {12: 5, north: 7}, then {3: 2, 12: 5, north: 7}: each number-like key moves in front of north. The run prints [ '3', '12', 'north' ]. After the edit, the run prints [ '1', '3', '12', 'north' ].
Expected output
[ '3', '12', 'north' ]05 / Changed keys
What happens when a key changes after it is stored?
A Map holds the key it was given. A string or a number is a value, and nothing can change it later. For an object, the Map holds a reference and compares identity, so changing the object’s properties does not lose the entry: it is still the same object.
The risk comes back when you build a string key from an object, as Equality and comparison suggests for a lookup by contents. The key is made from the contents at that moment. When the object changes later, the old key stays in the Map, and a key built from the new contents finds nothing.
To give an entry a different key, delete the old key and set the value under the new one.
const bed = { flower: "rose", row: 1 };
const plan = new Map<string, number>();
plan.set(bed.flower + "|" + bed.row, 3);
bed.row = 2;
// the stored key is still "rose|1"
console.log(plan.get(bed.flower + "|" + bed.row)); // undefined A key built from changing data goes stale; to change a key, delete the entry and set it again.
06 / Try it
Can you predict a fresh example?
Decide for each lookup whether the key is there and what is stored under it. The next lesson, Stack and heap, shows where values are stored while a program runs. Its link is at the end of the page.
const beds = new Map<string, number>();
beds.set("tulip", 2);
beds.set("iris", 0);
beds.set("tulip", 5);
console.log(beds.size, beds.get("iris"), beds.get("lily"), beds.has("iris"));
What is printed?
Reveal the reasoning
2 0 undefined true. The second set for tulip replaces 2 with 5, so the Map holds two entries. iris is there with the value 0. lily is missing, so get gives undefined. has("iris") is true because the key is there, even though its value is 0.
Practice tickets
- MAP-101 · Free seedlings are shown as not stocked → Free
- MAP-102 · The harvest board lists plots in the wrong order → Sign in · Pro
- MAP-103 · Renaming a flower bed loses plants or adds an empty bed → Sign in · Pro