Modelling real data without a browser · Lesson 57 of 77 · concept
Map and Set: when a plain object is the wrong tool
About 60 minutes.
By the end of this lesson you can
- Say two things a Map does that a plain object cannot.
- Use get, set, has, delete and size on a Map, and add and has on a Set.
- Explain why a Map or Set becomes {} when you serialise it, and convert it properly.
- Choose between a Map and a plain object for a given job, and defend the choice.
Where the plain object runs out
Explanation
The lookup table you built in lesson 3 is a good tool, and for string ids it is often the right one. But it has three limits you will run into, and once you know them the choice becomes easy rather than a matter of taste.
First, keys are always text. You cannot key by a number and get a number back, and you certainly cannot key by an object — every object flattens to the same text "[object Object]" and they all collide into a single entry.
Second, the object already answers to inherited names such as toString, so keys that come from data need Object.hasOwn every single time you touch them, and forgetting once is a bug.
Third, there is no straightforward count. You write Object.keys(table).length, which builds a whole array of names just to measure it, and you do it every time you want the number.
A Map is the answer to all three. It is a purpose-built collection of key-value pairs where the keys keep their type, nothing is inherited, and the count is a property.
The five things a Map does
Explanation
You create one with new Map(), optionally handing it an array of [key, value] pairs to start from. Then there are five operations and you will use all of them: set(key, value) stores, get(key) reads, has(key) asks whether the key is present, delete(key) removes, and size tells you how many pairs there are.
size is a property, not a method — map.size with no brackets. Everything else is a method with brackets. Getting this the wrong way round is the single most common Map typo.
get returns undefined for a key that was never set, exactly like a plain object. But because a Map has no inherited keys, undefined here really does mean absent, and has(key) tells you the difference between a key you never set and a key you set to undefined.
A Map remembers the order its keys were first inserted, and every way of walking it — for...of, forEach, spreading it into an array — follows that order. That is a guarantee you do not get from a plain object, where numeric-looking keys are reordered ahead of the rest.
Keys that stay themselves
A mental model
In a Map, the key 1 and the key "1" are two different keys, because a Map compares keys by value and type rather than converting them to text. So does an object used as a key: two different objects are two different keys, even if they hold identical properties, because they are different objects.
The comparison rule has a name, SameValueZero, and it matters in exactly one place: NaN. Ordinary comparison says NaN is not equal to itself, but SameValueZero treats NaN as equal to NaN, so you can store something under a NaN key and get it back. It also treats 0 and -0 as the same key.
Being able to key by an object sounds exotic until the first time you need it — attaching extra information to things you did not create, caching a result per record, marking which items you have already handled. All of those are Map jobs and none of them can be done with a plain object.
A Set is the same idea with the values removed
An analogy
If a Map is a phone book, a Set is a guest list. It answers exactly one question — is this on the list? — and it holds each entry once no matter how many times you add it.
new Set(someArray) is the shortest deduplicate in the language: it drops every repeat and keeps the first occurrence's position. Spreading it back with [...set] gives you an array again, in that order.
The operations are add(value), has(value), delete(value) and size. Note that it is add here and set there; a Set has no keys and values to pair up, only members.
Reach for a Set whenever you catch yourself writing array.includes(x) inside a loop. includes walks the array every time, exactly like find did in the last lesson; has does not.
The trap: a Map does not survive JSON
A common mistake
JSON.stringify(map) produces {} — an empty object — no matter what is in it. Nothing throws and nothing warns you. The same is true of a Set.
The reason is not a bug: JSON.stringify serialises an object's own enumerable properties, and a Map keeps its contents somewhere else entirely, not in ordinary properties. There is nothing for stringify to find.
So a Map is the wrong choice for anything you are about to save or send, unless you convert it first. Object.fromEntries(map) turns it into a plain object when the keys are strings, and [...map] turns it into an array of [key, value] pairs which survives JSON and can be handed straight back to new Map() on the other side. Choose the array form when the keys are not strings, because the object form would flatten them.
This is the clearest example in the whole module of the general rule: the shape you compute with and the shape you store are allowed to be different, and converting between them deliberately is better than picking one shape that is mediocre at both.
How to choose, in one paragraph
Why it matters
Use a plain object when the keys are a small, known set of names that you type yourself — a settings record, an options bag, anything you will serialise. Use a Map when the keys come from data, when there are many of them, when they are not strings, when you add and remove often, or when insertion order matters.
Use a Set instead of an array whenever the only question you ask is whether something is present, and duplicates are meaningless.
None of these are performance micro-decisions. They are about which mistakes the shape makes impossible. A Map cannot collide with toString; a Set cannot hold a duplicate. Choosing the container that rules out your bug is cheaper than remembering not to write it.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
A Map from creation to serialisation
The five operations, then the JSON trap and the two ways out of it. Pay attention to the fourth output line: it is empty, and nothing went wrong to make it empty.
1const stock = new Map();2stock.set("apples", 12);3stock.set("pears", 0);4 5console.log(stock.get("apples"));6console.log(stock.has("pears"));7console.log(stock.get("plums"));8console.log(stock.size);9 10console.log(JSON.stringify(stock));11console.log(JSON.stringify([...stock]));12console.log(JSON.stringify(Object.fromEntries(stock)));- line 6
has answers the presence question on its own. Note that pears holds 0 — with a plain object, if (table.pears) would have said "no pears" here, which is a real and common bug.
- line 7
get on a key that was never set gives undefined, the same as an object. The difference is that here undefined cannot mean an inherited name.
- line 8
size is a property, so there are no brackets. This is the typo you will make once.
- line 10
The trap. A Map's contents are not ordinary properties, so JSON.stringify finds nothing to serialise and produces an empty object. No error, no warning.
- line 11
Spreading a Map gives an array of [key, value] pairs, in insertion order. This survives JSON and can be passed straight to new Map() to rebuild it.
- line 12
Object.fromEntries gives the plain-object form instead, which is nicer to read but only correct when every key is already a string.
What it prints
12trueundefined2{}[["apples",12],["pears",0]]{"apples":12,"pears":0}
A Set: uniqueness, order, and the NaN rule
Deduplicating in one call, and the one comparison rule that differs from ordinary equality. The last two lines show why a Set answers a membership question better than an array does.
1const tags = ["news", "sport", "news", "news"];2const unique = new Set(tags);3 4console.log(unique.size);5console.log(JSON.stringify([...unique]));6console.log(unique.has("sport"));7console.log(unique.has("weather"));8 9console.log(new Set([NaN, NaN]).size);10console.log(NaN === NaN);- line 2
Handing an array to new Set drops every repeat in one step. The original array is untouched.
- line 5
Spreading gives an array back, in first-appearance order: news came first in the input, so it comes first here.
- line 9
Two NaN values become one member, because a Set compares with SameValueZero, which treats NaN as equal to itself.
- line 10
Ordinary comparison disagrees: NaN === NaN is false. Both statements are true at once; they are simply different comparison rules.
What it prints
2["news","sport"]truefalse1false
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
The same number is used as a key in a Map and in a plain object. What do the four lines print?
const byNumber = new Map();byNumber.set(1, "one");console.log(byNumber.get(1));console.log(byNumber.get("1"));const asObject = {};asObject[1] = "one";console.log(asObject["1"]);console.log(JSON.stringify(Object.keys(asObject)));
What does JSON.stringify(new Map([["a", 1]])) produce?
You are counting how many times each word appears in a document supplied by a user. Which container, and why?
Write it yourself
Exercise · core · 8 tests
Count words without tripping over toString
Write countWords(words) that takes an array of words and returns a Map from each word to the number of times it appears. Walk the list once. Keys must appear in the Map in the order the words were first seen. The array you were given must come back untouched.
Returns a Map whose keys are the distinct words in first-appearance order and whose values are the counts. An empty array gives a Map of size 0. A word that never appeared reads back as undefined. Words such as toString are counted like any other word. The tests read the returned Map, not anything you printed — and remember that printing a Map shows {} in this sandbox, so use size or spread it into an array while you are experimenting.
Define countWords at the top level of your code — the tests call it by name.
Worth remembering
- Three things a Map does that a plain object cannot?
Keys keep their type (including objects), there are no inherited keys to collide with, and the count is a size property. It also guarantees insertion order for every way of walking it.
- What does JSON.stringify do to a Map or a Set?
Produces {}, silently, because their contents are not ordinary properties. Convert first: [...map] for an array of pairs that round-trips, or Object.fromEntries(map) when the keys are strings.
- When should an array be a Set instead?
When the only question you ask is whether something is present and duplicates are meaningless. includes walks the whole array each time; has does not.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: The Description section: a Map's keys may be any value, a Map remembers the original insertion order of its keys, and the number of pairs is read from the size property.
Check there: That a Set stores each value once and compares with SameValueZero, under which NaN is considered equal to NaN.
MDN — JavaScript Guide: Keyed collections
Check there: The comparison of Map with Object, including that object keys are strings or symbols while Map keys can be any value.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown