Quick Answer

A plain object converts every key to a string, so 1 and '1' become the same entry and any object key becomes the literal text [object Object]. Map keeps keys as they are, keeps insertion order, exposes size, and has no inherited property names to collide with. Set stores unique values using SameValueZero, so NaN deduplicates but two identical-looking objects do not. WeakMap and WeakSet hold their keys weakly so entries can be garbage collected.

Object keys are strings, and that is the whole problem

Objects were never designed as a general purpose dictionary. Property names can only be strings or symbols, so anything else you use as a key is converted first. Most of the time you never notice. Then one day a numeric ID and its string form land in the same object.

const counts = {};
counts[1] = 'from the number';
counts['1'] = 'from the string';

Object.keys(counts);  // ['1']  - one key, not two
counts[1];            // 'from the string'

The version that causes real data loss is using objects as keys. Conversion calls toString(), and every plain object returns the same text.

const a = { id: 1 };
const b = { id: 2 };

const city = {};
city[a] = 'Pune';
city[b] = 'Nagpur';

console.log(city);  // { '[object Object]': 'Nagpur' }

Two entries went in, one came out, no error. If a and b were student records in a loop, you have just lost every value except the last.

The second problem is inherited names. A plain object starts life with a prototype, so keys you never added appear to exist.

const seen = {};
'constructor' in seen;   // true
seen['toString'];        // f toString()

If your dictionary keys come from user input, a form field or a URL parameter, then a key named constructor, toString or __proto__ behaves unlike every other key. Assigning to __proto__ does not even create an entry, because it triggers a setter on the prototype instead. Objects also give you no count, so you compute Object.keys(obj).length, which builds a throwaway array each time.

What Map gives you that an object cannot

Map is a purpose-built key to value store. Keys keep their type and their identity, so objects, numbers, booleans, functions and even NaN all work as distinct keys.

const fees = new Map();
const aarav = { roll: 'CS-21' };
const riya  = { roll: 'CS-22' };

fees.set(aarav, 4500).set(riya, 3200);

fees.get(aarav);   // 4500
fees.size;         // 2
fees.has(riya);    // true
fees.delete(riya); // true
fees.get(riya);    // undefined

set returns the map, so calls chain. get on a missing key returns undefined, and has is the honest membership test, with no prototype to lie to you: fees.has('constructor') is false on a fresh map.

Iteration order is a second real difference. A Map iterates strictly in insertion order. An object does not: integer-like keys come out first, sorted ascending, before the rest in insertion order.

const o = { '10': 'ten', '2': 'two', name: 'Riya' };
Object.keys(o);              // ['2', '10', 'name']

const m = new Map([['10', 'ten'], ['2', 'two'], ['name', 'Riya']]);
[...m.keys()];               // ['10', '2', 'name']

That bites when the keys are pincodes, roll numbers or years and you expected the order you inserted. Maps are also directly iterable, so for (const [key, value] of fees) works without Object.entries, and adding or deleting keys is a genuine operation rather than reshaping a hidden object structure, which is why a Map is the better choice for a table that changes constantly.

The frequency-counting pattern that shows up in almost every coding round reads more cleanly as a result, and it handles keys of any type without you thinking about conversion.

const counts = new Map();
for (const word of words) {
  counts.set(word, (counts.get(word) ?? 0) + 1);
}

// highest count first
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];

Set vs array, and the equality rule

Set stores unique values. The classic one-liner for deduplicating an array is genuinely the best tool for the job.

const cities = ['Pune', 'Nagpur', 'Pune', 'Surat'];
const unique = [...new Set(cities)];  // ['Pune', 'Nagpur', 'Surat']

The equality rule is SameValueZero, which is === with one change: NaN equals itself. That produces a pleasant surprise and an unpleasant one.

new Set([NaN, NaN]).size;   // 1
[NaN].indexOf(NaN);         // -1  - === says NaN is not NaN
[NaN].includes(NaN);        // true - includes uses SameValueZero too

new Set([{ id: 1 }, { id: 1 }]).size;  // 2 - different objects

So a Set will never deduplicate objects that merely look alike. If you are removing duplicate records fetched from an API, build a Map keyed by the ID and take [...map.values()], or make a Set of a stable string key.

The other reason to prefer a Set is membership testing. array.includes(x) scans elements one by one, so checking each of your items against a list of blocked values means walking that list every single time. set.has(x) is designed not to scan the collection at all. On a handful of values the difference does not matter; inside a loop over thousands of rows it is the difference between a page that responds and one that hangs.

const blocked = new Set(['test@example.com', 'spam@example.com']);
const clean = signups.filter(s => !blocked.has(s.email));

What a Set does not give you is indexing or sorting. There is no set[0], no map, no filter. Spread it back into an array when you need those.

WeakMap and WeakSet: keys that can be collected

A normal Map keeps its keys alive. If you use DOM nodes as keys and store one entry per node, then removing those nodes from the page does not free them, because your Map is still holding a reference. The page keeps growing. That is a leak, and it is a common one in dashboards that rebuild lists.

WeakMap exists for exactly this. Its keys must be objects, or in newer engines non-registered symbols, and it holds them weakly: if nothing else in the program references a key, the garbage collector is free to remove both the key and its value.

const meta = new WeakMap();

function attach(row, record) {
  meta.set(row, record);          // row is a DOM element
}

function readRecord(row) {
  return meta.get(row);
}

// When the row is removed from the DOM and forgotten,
// its entry in `meta` becomes collectable automatically.

The price is that a WeakMap is deliberately limited. It has only get, set, has and delete. There is no size, no iteration, no clear, because the contents can change at any moment when collection happens and exposing that would make garbage collection observable.

WeakSet is the same idea without values, useful for marking objects as already processed without preventing them from being freed.

const validated = new WeakSet();

function validate(order) {
  if (validated.has(order)) return;
  // ... run expensive checks once per order object
  validated.add(order);
}

Use these two whenever you are attaching extra information to objects whose lifetime you do not control: DOM nodes, request objects, instances handed to you by a framework. Use a normal Map when you own the lifetime and want to list what is inside.

When a plain object or array is still the right call

Map is not a replacement for the object literal, and switching everything over is a mistake. The single biggest reason is serialisation.

JSON.stringify(new Map([['city', 'Pune']]));   // "{}"
JSON.stringify(new Set(['Pune']));             // "{}"

Both serialise to an empty object, silently. If you build a request body from a Map, your API receives nothing. Convert first, and convert back on the way in.

const m = new Map([['city', 'Pune'], ['pin', '411001']]);

const asObject = Object.fromEntries(m);   // { city: 'Pune', pin: '411001' }
const asPairs  = [...m];                  // [['city','Pune'], ['pin','411001']]

const back = new Map(Object.entries(asObject));

Keep plain objects for records with a fixed, known shape: a user, a config block, an API response. Those have named fields, not dynamic keys, and dot access reads better than get. Keep arrays when order and index matter, when you need map, filter, sort and slice, or when duplicates are meaningful data rather than noise.

Reach for a Map when keys are added and removed at runtime, when keys are not strings, when insertion order must be preserved exactly, or when keys come from outside your code and could collide with inherited property names. Reach for a Set when you need uniqueness or repeated membership checks. Reach for the weak versions when you are keying off objects you do not own.

One in-between option worth knowing: Object.create(null) gives you an object with no prototype, so no inherited keys and no __proto__ setter. It is a reasonable lightweight dictionary when the keys are strings and you still want JSON.stringify to work.

Two smaller habits save time later. Do not treat Map as a faster object, because engines optimise fixed-shape objects heavily and a record with known fields is already fast. And when you convert a Map back to an object with Object.fromEntries, remember that non-string keys are stringified on the way, which quietly reintroduces exactly the collision you switched to a Map to avoid.

Frequently Asked Questions

Is Map always faster than a plain object? No, and treating it as a speed upgrade is the wrong mental model. Engines optimise objects with a fixed set of known properties very aggressively, so a record-shaped object is excellent. Map is designed for collections whose keys are added and deleted frequently and are not known ahead of time. Choose based on how the keys behave, not on a general claim about speed.
Why does JSON.stringify return {} for a Map? Because JSON has no representation for a Map, and stringify only serialises own enumerable properties. A Map keeps its entries in internal storage, not as properties, so there is nothing for stringify to find. Convert with Object.fromEntries(map) when the keys are strings, or with [...map] to keep an array of pairs, and rebuild it with new Map() on the other side.
Why does a Set not remove duplicate objects? A Set compares values with SameValueZero, which for objects means reference identity. Two object literals with identical contents are two different objects, so both are kept. To deduplicate records, pick a stable identifier and build a Map keyed by that identifier, then read map.values(). Only NaN gets the special treatment, where a Set treats it as equal to itself.
When should I use WeakMap instead of Map? Use WeakMap when the keys are objects whose lifetime someone else controls, such as DOM elements, request objects or framework instances, and you only want to attach side information. A normal Map would keep those objects alive forever and leak memory. The trade-off is that a WeakMap cannot be iterated and has no size, so it is unsuitable when you need to list what you stored.
Can I use a Map or Set in an interview answer about hash tables? Yes, and it is usually the expected answer in JavaScript. Map and Set give you constant-time average lookup without you writing a hash function, which is exactly what problems like two-sum, frequency counting and duplicate detection need. Mention that a plain object also works for string keys but converts keys to strings and inherits property names, and you have shown the interviewer you know why the distinction exists.