JAVASCRIPT / MAPS, SETS, SYMBOLS, AND PROXIES
Choosing Map, Set, or plain objects
Decide between a plain object, a Map, and a Set by asking whether keys are source-code names or runtime data, and convert safely at JSON boundaries.
What you will learn
- Use object literals for fixed field names, Map for keys that arrive at runtime
- Spot the object hazards: string-coerced keys, inherited names, reordered integer keys
- Replace array.includes inside loops with Set.has for O(1) membership checks
- Convert at JSON boundaries with Object.fromEntries(map) and [...set]
Understanding Choosing Map, Set, or plain objects
A plain object and a Map both associate keys with values, but they answer different questions. An object literal describes a record: the field names are part of your source code, you read them with dot access, you destructure them, and you hand them straight to JSON.stringify. A Map describes a collection whose keys are data that arrives at runtime from a request, a DOM node, or another object, with a shape you cannot write down while typing. The decision is not old versus modern; it is whether these keys are names in your program or values in your data.
The mechanical differences all follow from that split. Object keys are strings or symbols, so anything else is coerced: 3 and '3' land in the same slot, and every object stringifies to '[object Object]'. Objects also inherit from Object.prototype, so 'toString' in obj is true before you store anything, and integer-like keys iterate in ascending numeric order instead of insertion order. A Map keeps its entries in internal storage rather than as properties, so keys retain their type and identity, insertion order is exact, size is a constant-time read, and repeated adds and deletes are the case it is tuned for.
A Set is a Map with the values discarded: the only question it answers is whether a value is present. That makes it the right replacement for array.includes called inside a loop, which rescans from the start every time and turns an O(n) job into O(n squared), and for the object-keyed-by-true idiom, which drags string coercion and inherited names back in. Set and Map compare with SameValueZero, so NaN finds itself where indexOf cannot, while objects match by identity, meaning two structurally identical objects are two separate members. What you give up with both is index access, spread of named fields, and free serialisation, which is exactly why records stay plain objects.
const alice = { name: 'Alice' };
const bob = { name: 'Bob' };
const objTotals = {};
objTotals[alice] = 10;
objTotals[bob] = 20;
console.log('object key count:', Object.keys(objTotals).length);
console.log('the only key:', Object.keys(objTotals)[0]);
console.log('alice total:', objTotals[alice]);
const mapTotals = new Map();
mapTotals.set(alice, 10);
mapTotals.set(bob, 20);
console.log('map size:', mapTotals.size);
console.log('alice total:', mapTotals.get(alice));
console.log('inherited on object:', 'toString' in objTotals);
console.log('inherited on map:', mapTotals.has('toString'));A plain object models a record whose field names you write in the source, while Map and Set model collections whose keys and members are runtime data.
Worked examples
Integer-like keys reorder themselves
Shows that an object uses two different key orderings at once while a Map uses only insertion order.
const obj = { '10': 'ten', '2': 'two', b: 'bee', a: 'ay' };
const map = new Map([['10', 'ten'], ['2', 'two'], ['b', 'bee'], ['a', 'ay']]);
console.log('object:', Object.keys(obj).join(' '));
console.log('map:', [...map.keys()].join(' '));Example explained
Line 1'10' and '2' look like array indices, so the object lists them in ascending numeric order ahead of everything else.
Line 2'b' and 'a' are not index-like, so they keep insertion order, which means one object mixes two orderings.
Line 3Map iterates every key in insertion order regardless of its type, which is what you need for numeric IDs, ZIP codes, or paginated results.
Set membership versus object keys
Compares how a Set and an object-used-as-a-set treat identical-looking values.
const raw = [3, '3', 1, 3, NaN, NaN];
const unique = new Set(raw);
console.log('set size:', unique.size);
console.log('set has NaN:', unique.has(NaN), '| array indexOf NaN:', raw.indexOf(NaN));
const asKeys = {};
for (const v of raw) asKeys[v] = true;
console.log('object key count:', Object.keys(asKeys).length);
console.log('object keys:', Object.keys(asKeys).join(','));Example explained
Line 1The Set keeps the number 3 and the string '3' as distinct members, so it holds four values.
Line 2Writing asKeys[v] runs String(v) on the key, so 3 and '3' collapse into one slot and NaN becomes the text 'NaN'.
Line 3Set lookups use SameValueZero, so has(NaN) succeeds where indexOf(NaN) returns -1 because it compares with strict equality.
Line 4The size mismatch, 4 against 3, is data silently lost by the object version.
Crossing a JSON boundary
Demonstrates why a Map or Set sent through JSON.stringify arrives empty and how to convert instead.
const config = { retries: 3, timeout: 500 };
const configMap = new Map([['retries', 3], ['timeout', 500]]);
const flags = new Set(['a', 'a', 'b']);
console.log(JSON.stringify(config));
console.log(JSON.stringify(configMap));
console.log(JSON.stringify(flags));
console.log(JSON.stringify(Object.fromEntries(configMap)));
console.log(JSON.stringify([...flags]));Example explained
Line 1JSON.stringify only walks own enumerable string-keyed properties, and a Map holds its entries in internal slots, so nothing is visible to it.
Line 2A Set serialises to {} for the same reason, which is why a lost payload shows up as an empty body rather than an error.
Line 3Object.fromEntries(configMap) rebuilds a record, but it is only safe while the keys are strings, since object keys get stringified.
Line 4Spreading a Set into an array gives the natural JSON form for a list of members.
Important notes
Object.create(null) removes the inherited-key hazard but keys are still coerced to strings, and the result has no toString, so putting it in a template literal throws a TypeError.
Map and Set match keys and members with SameValueZero: NaN equals itself and 0 and -0 collapse, but two structurally identical objects remain two separate entries.
Common mistakes
Keying a plain object with user input and guarding with if (cache[key]): keys like 'constructor' and 'toString' return inherited values, so the code reports cache hits for entries that were never stored.
Using objects as object keys, as in totals[user] = 10: every user stringifies to '[object Object]', so they all share a single slot and the last write silently wins.
Calling JSON.stringify on a Map or Set: it returns {} with no warning, so the request body or localStorage entry ends up empty and the bug surfaces far from its cause.
Try it yourself
Change, predict, then run
Take a sentence that contains the words constructor and toString, dedupe its words twice, once with a Set and once with an object keyed by each word, and log both counts. Then compare set.has('toString') with 'toString' in obj and explain why they disagree.
Open the JavaScript workspaceCheck your understanding
A counter is keyed by strings taken from a URL query parameter and read with if (counts[key]). Why is a plain object literal unsafe here in a way a Map is not?
- Objects reject non-string keys, so numeric query values are never stored at all
- Property reads on objects are O(n), so lookups get slower than Map.get as keys accumulate
- The object inherits from Object.prototype, so a key like 'constructor' looks like a stored count that was never added
- Objects do not preserve insertion order, so the counter reads back the wrong key
Show answer
counts.constructor resolves through the prototype chain and is truthy, so the guard passes for keys nobody counted, and assigning counts['__proto__'] does not even create an own property; a Map has no prototype chain for its entries, so has() only reports what you stored. The ordering option is tempting because objects really do list integer-like keys in numeric order first, but that changes iteration order only, not whether a lookup finds a value that was never set.