Your Object Is Not a Hash Map: Seven Expressions Most People Get Wrong

✓ numbers produced by the code on this page · Published 2026-08-20

Almost every JavaScript developer has been told to use a Map instead of a plain object when the keys are data rather than code. Almost nobody does it, and the reason is that the advice always arrives as a list of properties: any key type, insertion order preserved, no prototype.

Read as a list, none of those sounds like it would ruin an afternoon. Each of them ruins an afternoon in a specific way.

Predict, then find out

Predict the value seven expressions, and most people miss three

"Use a Map instead of an object for dictionaries" is advice most people have heard and few follow, because the reasons are always given as a list of properties — any key type, insertion order preserved, no prototype — and read as a list, none of them sounds like it would ruin an afternoon.

Each one ruins an afternoon in a specific way. An object's keys are always strings, so obj[1] and obj["1"] are one key. Every object used as a key becomes the string "[object Object]", so two entirely different objects collide silently. Integer-like keys are not stored in insertion order — { b: 1, 2: 2, a: 3, 1: 4 } yields the keys ["1","2","b","a"]. And "toString" in is true on an object you created a moment ago, because in walks the prototype chain.

A Map has none of these behaviours: keys keep their type, keys are compared by identity, insertion order is preserved for every key, and there is no prototype for anything to leak through.

Answer before reading the explanation. Being wrong is the part that sticks.

What the runtime actually does

javascript
const obj = {};
obj[1] = "number one";
obj["1"] = "string one";
console.log("obj[1] and obj['1'] are:", Object.keys(obj).length, "key ->", JSON.stringify(obj));

const withOrder = { b: 1, 2: 2, a: 3, 1: 4 };
console.log("insertion order was b, 2, a, 1 — Object.keys gives:", JSON.stringify(Object.keys(withOrder)));

const m = new Map();
m.set(1, "number one");
m.set("1", "string one");
console.log("Map keeps them apart:", m.size, "keys ->", JSON.stringify([...m.keys()]));
console.log("Map keeps insertion order:", JSON.stringify([...new Map([["b",1],[2,2],["a",3],[1,4]]).keys()]));

const plain = {};
console.log("'toString' in {} is:", "toString" in plain);
console.log("Object.hasOwn({}, 'toString') is:", Object.hasOwn(plain, "toString"));
console.log("{}.toString is:", typeof plain.toString);
console.log("new Map().get('toString') is:", new Map().get("toString"));

const objKey = { id: 1 };
const same = { id: 1 };
const byRef = new Map([[objKey, "first"]]);
console.log("Map with an object key, looked up by an equal-but-different object:", byRef.get(same));
const asObj = {};
asObj[objKey] = "first";
console.log("Object with an object key becomes:", JSON.stringify(Object.keys(asObj)), "and asObj[same] is:", asObj[same]);
Output
obj[1] and obj['1'] are: 1 key -> {"1":"string one"}
insertion order was b, 2, a, 1 — Object.keys gives: ["1","2","b","a"]
Map keeps them apart: 2 keys -> [1,"1"]
Map keeps insertion order: ["b",2,"a",1]
'toString' in {} is: true
Object.hasOwn({}, 'toString') is: false
{}.toString is: function
new Map().get('toString') is: undefined
Map with an object key, looked up by an equal-but-different object: undefined
Object with an object key becomes: ["[object Object]"] and asObj[same] is: first

Four failures, in the order they will cost you

Keys are coerced to strings. Storing under 1 and under "1" gives one key, not two. This is harmless until you have a cache keyed by ids that arrive sometimes as numbers and sometimes as strings from a query parameter, at which point two code paths quietly share one entry.

Every object used as a key becomes "[object Object]". Look at the last line: asObj[same] returns "first" even though same is a completely different object that was never used as a key. All objects collapse to the same key, so a lookup succeeds when it should fail. This is the one that produces bugs nobody can reproduce, because the code reads correctly and the mistake is in the language’s conversion rules rather than in the logic. A Map returns undefined here, which is the right answer.

Integer-like keys are reordered. Inserting b, 2, a, 1 and reading the keys back gives ["1","2","b","a"]. Keys that look like array indices are moved to the front in ascending numeric order, and only the remaining string keys keep insertion order. Anything that serialises an object and expects stable ordering — a cache key, a signature, a diff — is resting on a rule with an exception in it. Map has no exception.

The prototype is in the way. "toString" in {} is true on an object created a line earlier, because in walks the prototype chain. Any membership test against keys from outside your program needs Object.hasOwn, or it will report keys nobody inserted. Map has no prototype for anything to leak through, which is the practical reason to reach for it whenever keys are user-controlled.

So when is a plain object still right?

Most of the time, and this page is not an argument that it never is.

Use an object when the keys are part of your program — fixed, known while writing the code, and few. A configuration object, a lookup table of handlers, the shape of a record. Object literals are shorter to write, serialise to JSON directly, and are what every API in the ecosystem expects.

Reach for a Map when the keys are data: user input, ids from a database, objects, anything arriving at runtime that you did not choose. That is the line, and it is a much more usable rule than “Map is better”.

What this page simplifies