Key-Value Pairs in JavaScript: Objects vs Maps
Key-Value Pairs in JavaScript: Objects vs Maps
The cache was keyed by user object. Two hundred users went in, and one entry came out.
Nothing in the code was misspelled. Every cache[user] wrote to the same property, because a plain object turns every key that is not a symbol into a string, and every plain object turns into the string "[object Object]".
JavaScript gives you more than one key-value store, and almost every difference between them is a difference in what happens to the key and where the entry is stored. This article walks through what your key becomes, what order keys come back in, which enumeration API sees which of them, and how to pick between a plain object, a Map and a WeakMap.
What a key-value pair is in JavaScript
A key-value pair is an association between a key you look something up by and the value you get back. Three kinds of store hold them, and the rest of this article keeps referring to these three.
Plain objects are the first. Their keys are strings or symbols, their entries are properties, and every one of them inherits from Object.prototype unless you take that prototype away. A null-prototype object is a variant of this category and not a fourth kind of store: it is a plain object with the inheritance removed.
Map and WeakMap are the second. Their keys can be any value at all, WeakMap excepted, where a key must be an object or a non-registered symbol. Their entries are not properties, which is why nothing that reads properties can see them.
Platform multi-maps are the third: URLSearchParams, FormData and similar APIs where one key legitimately holds several values. They are the reason getAll exists next to get.
Here are the first two next to each other:
const roomsObject = {
garden: 4,
library: 12,
};
const roomsMap = new Map([
['garden', 4],
['library', 12],
]);
console.log(roomsObject.garden, roomsMap.get('garden'));
console.log(Object.keys(roomsObject).length, roomsMap.size);
console.log('toString' in roomsObject, roomsMap.has('toString'));
4 4
2 2
true false
Same two pairs, three visible differences. You read a property with .garden and an entry with .get('garden'). You count properties by materialising an array of them and count entries with size. And 'toString' is already in the object before you write anything, because it lives on Object.prototype, while the Map holds two entries and nothing else. Objects and Map and Set cover each store on its own.
The third category is the one people forget until a query string bites them:
const params = new URLSearchParams('room=garden&room=library&guests=3');
console.log(params.get('room'));
console.log(params.getAll('room').join(', '));
console.log(params.get('guests'));
garden
garden, library
3
get returns the first value associated with the key. getAll returns all of them as an array. FormData works the same way, and reaching for get on a field that can repeat is how one of several uploaded files quietly goes missing.
Adding, reading and removing keys
Dot notation reads and writes a property whose name is a valid identifier. Bracket notation takes any string, which is why it is mandatory more often than people expect:
const room = { name: 'garden' };
const field = 'guest count';
room.floor = 1;
room[field] = 3;
room['2024-review'] = 'quiet';
const key = 'name';
console.log(room[key]);
console.log(room['guest count']);
console.log(Object.keys(room).join(' | '));
garden
3
name | floor | guest count | 2024-review
A property name can be any string, including an empty one, but dot notation cannot reach a name with a space or a hyphen in it, a name starting with a digit, or a name held in a variable. All four of those need brackets. The same expression form works inside a literal as a computed key: { [field]: 3 }.
Merging is where a detail hides. Object.assign and object spread both copy enumerable own properties, and both read the source through its getters, so a getter runs and the result is a plain data property:
const defaults = { floor: 0, seats: 2 };
const overrides = {
seats: 8,
get label() {
console.log('getter ran');
return 'atrium';
},
};
const merged = { ...defaults, ...overrides };
console.log(merged.seats, merged.label);
console.log(Object.getOwnPropertyDescriptor(merged, 'label').get);
getter ran
8 atrium
undefined
The getter ran once, during the spread, and the copy has no getter left. The write side is where the two part company: spread defines data properties on the new object, while Object.assign writes through [[Set]] and so fires setters the target inherits, which is why Object.assign({}, JSON.parse(input)) can move the prototype and { ...JSON.parse(input) } cannot, through the same inherited __proto__ accessor that Choosing between object, Map and WeakMap comes back to. That is the whole reason “copy the object” and “copy the object’s behaviour” are different jobs, which Object references and copying takes further.
Three membership checks exist and they answer three different questions:
const room = { name: 'garden', guests: 0, notes: undefined };
console.log('notes' in room, Object.hasOwn(room, 'notes'));
console.log(Boolean(room.guests), 'guests' in room);
console.log(delete room.name, room.name);
console.log('toString' in room, Object.hasOwn(room, 'toString'));
true true
false true
true undefined
true false
in walks the prototype chain, Object.hasOwn does not, and a truthiness check on the value answers neither question: guests is present and holds 0. The last line is the gap that matters, and it is why Object.hasOwn is the one to reach for on anything that behaves as a dictionary.
The Map equivalents are methods, and one of them returns something useful:
const rooms = new Map();
rooms.set('garden', 4).set('library', 12);
console.log(rooms.get('garden'), rooms.get('attic'));
console.log(rooms.has('library'), rooms.delete('library'), rooms.delete('library'));
rooms.clear();
console.log(rooms.size);
4 undefined
true true false
0
set returns the Map, so it chains. map.delete() returns true when it removed something and false when there was nothing to remove, which is a signal you can act on. The delete operator returns a boolean too, but it reports whether the property is gone rather than whether it was ever there, so it comes back true for a key that never existed.
What your key actually becomes
Here is the rule the reference pages state in passing and then move on from.
const store = {};
store[1] = 'first';
console.log(store['1'], Object.keys(store)[0], typeof Object.keys(store)[0]);
const alice = { id: 1 };
const bob = { id: 2 };
store[alice] = 'alice';
store[bob] = 'bob';
console.log(store[alice], Object.keys(store).length);
console.log(Object.keys(store).join(' | '));
first 1 string
bob 2
1 | [object Object]
Writing store[1] created the property named '1', a string, and reading store['1'] finds it. Then two distinct objects were used as keys and one property came out of it: both converted to "[object Object]", so store[bob] = 'bob' overwrote what alice had written, and the object now holds two properties rather than three. Nothing threw, nothing warned, and the value under alice is 'bob'.
That is the bug from the opening, and it is one line of code. Object to primitive conversion covers what produces that string.
A Map compares keys with the SameValueZero algorithm, which changes three things at once:
const seen = new Map();
seen.set(NaN, 'not a number');
seen.set(0, 'zero');
seen.set(-0, 'negative zero');
const alice = { id: 1 };
seen.set(alice, 'alice');
seen.set({ id: 1 }, 'a different object');
console.log(seen.get(NaN));
console.log(seen.get(0), seen.size);
console.log(seen.get(alice), seen.get({ id: 1 }));
not a number
negative zero 4
alice undefined
NaN works as a key, even though NaN !== NaN, because SameValueZero treats it as the same as itself. 0 and -0 are one key, so the third set updated the second entry rather than adding a third. Every other value follows ===, which for objects means identity: the fresh { id: 1 } literal on the last line is a different object from alice, so it is a different key, and the four entries are NaN, 0, alice and that second literal. Comparisons has the equality algorithms side by side.
The rule that falls out of both halves, and the one the choosing section leans on: if your keys are not strings, a plain object silently merges them and a Map does not.
Key order and who sees which keys
Objects reorder their keys. Not arbitrarily, and not by insertion:
const mixed = { 100: 'a', banana: 'b', 2: 'c', apple: 'd', 7: 'e' };
console.log(Object.keys(mixed).join(', '));
const ordered = new Map([
[100, 'a'],
['banana', 'b'],
[2, 'c'],
]);
console.log([...ordered.keys()].join(', '));
2, 7, 100, banana, apple
100, banana, 2
Within each component of the prototype chain, all non-negative integer keys are traversed first in ascending order by value, then the other string keys in ascending chronological order of property creation. Object.keys returns the same order a for...in loop provides, which is why 100 landed last among the numbers and banana still precedes apple. The Map did no reordering: 100, banana, 2, exactly as inserted.
Symbols are a third tier, and non-enumerable properties are a second axis on top of that:
const tag = Symbol('tag');
const record = { name: 'garden', 2: 'second', [tag]: 'x', 1: 'first' };
Object.defineProperty(record, 'internal', { value: 'hidden', enumerable: false });
console.log(Object.keys(record).join(', '));
console.log(Object.getOwnPropertyNames(record).join(', '));
console.log(Object.getOwnPropertySymbols(record).length);
console.log(Reflect.ownKeys(record).map(String).join(', '));
1, 2, name
1, 2, name, internal
1
1, 2, name, internal, Symbol(tag)
Object.keys dropped both internal, which is non-enumerable, and the symbol. getOwnPropertyNames picked up internal and still skipped the symbol. Reflect.ownKeys returned everything, with the symbol last. The Symbol type explains why that third tier exists.
Which API sees what is a single table, and it is worth annotating:
| API | Reach | Enumerability | Key types |
|---|---|---|---|
Object.keys, Object.values, Object.entries | own only | enumerable only | strings |
Object.getOwnPropertyNames | own only | enumerable and non-enumerable | strings |
Object.getOwnPropertySymbols | own only | enumerable and non-enumerable | symbols |
Reflect.ownKeys | own only | enumerable and non-enumerable | strings and symbols |
for...in | own and inherited | enumerable only | strings |
Object.assign, object spread | own only | enumerable only | strings and symbols |
The for...in row is the odd one, and it is why the loop is usually the wrong one to reach for:
const base = { shared: true };
const child = Object.create(base);
child.name = 'garden';
const seenIn = [];
for (const key in child) seenIn.push(key);
console.log(seenIn.join(', '));
console.log(Object.keys(child).join(', '));
name, shared
name
for...in climbed into the prototype and returned a key nobody put on child. It also ignores symbol keys entirely. Object.keys stays on the object in front of you, which is what almost every loop over a dictionary means.
Object.keys, values, entries and fromEntries
The three static methods share one contract: own, enumerable, string-keyed. Object.entries returns those as [key, value] pairs, which destructure straight into a for...of head:
const guests = { garden: 4, library: 12, attic: 2 };
for (const [room, count] of Object.entries(guests)) {
console.log(`${room} -> ${count}`);
}
garden -> 4
library -> 12
attic -> 2
Object.fromEntries runs that in reverse, taking a list of pairs and building an object. Between the two you get array methods on something that is not an array:
const guests = { garden: 4, library: 12, attic: 2 };
const busy = Object.fromEntries(
Object.entries(guests)
.filter(([, count]) => count > 3)
.map(([room, count]) => [room.toUpperCase(), count]),
);
console.log(JSON.stringify(busy));
{"GARDEN":4,"LIBRARY":12}
attic fell out at the filter, and the two survivors came back through fromEntries with new keys. Object.fromEntries has been available across browsers since January 2020, and Object.keys, values, entries works through the trio in detail.
The same pair converts between the two stores, and shows why one direction looks broken:
const rooms = new Map([['garden', 4], ['library', 12]]);
console.log(JSON.stringify(Object.fromEntries(rooms)));
console.log(Object.keys(rooms).length, Object.entries(rooms).length);
const back = new Map(Object.entries({ garden: 4, library: 12 }));
console.log(back.get('library'), back.size);
{"garden":4,"library":12}
0 0
12 2
Object.fromEntries accepts the Map directly, because a Map is already iterable as [key, value] pairs. The middle line is the one people file bugs about: Object.keys(someMap) is an empty array, and so is Object.entries(someMap). A Map keeps its entries in internal slots rather than as own properties, so a property-reading API finds nothing to read. Nothing is wrong, and no error is coming.
Grouping with Object.groupBy and Map.groupBy
Grouping got its own methods, both available since March 2024:
const bookings = [
{ room: 'garden', guests: 4 },
{ room: 'library', guests: 12 },
{ room: 'garden', guests: 2 },
];
const byRoom = Object.groupBy(bookings, (booking) => booking.room);
console.log(byRoom.garden.length, byRoom.library.length);
console.log(Object.getPrototypeOf(byRoom));
console.log('hasOwnProperty' in byRoom);
2 1
null
false
Object.groupBy returns a null-prototype object, which is the same shape Object.create(null) gives you, arriving by default: every group is a property holding an array, and nothing is inherited. The callback receives the element and its index, so (booking, index) => ... is available when the position matters.
Map.groupBy returns a Map, which means the group keys can be objects, with the identity rule from earlier still in force:
const garden = { room: 'garden' };
const library = { room: 'library' };
const bookings = [
{ place: garden, guests: 4 },
{ place: library, guests: 12 },
{ place: garden, guests: 2 },
];
const byPlace = Map.groupBy(bookings, (booking) => booking.place);
console.log(byPlace.size);
console.log(byPlace.get(garden).length);
console.log(byPlace.get({ room: 'garden' }));
2
2
undefined
To read a group back you must pass the same object that was used as the key. You may modify that object’s properties and still find the group, but another object with identical properties will not match. Grouping with Object.groupBy and Map.groupBy covers both.
Map, WeakMap and getOrInsert
Counting things is the pattern every Map tutorial reaches, and the classic version does three lookups per item:
const words = ['we', 'ship', 'code', 'every', 'day', 'we', 'ship'];
const counts = new Map();
for (const word of words) {
if (!counts.has(word)) counts.set(word, 0);
counts.set(word, counts.get(word) + 1);
}
console.log(counts.get('we'), counts.get('ship'), counts.size);
2 2 5
has, then get, then set. The TC39 upsert proposal, championed by Daniel Minor of Mozilla, reached Stage 4, finished and in the spec, and collapses the first two into one call: Map.prototype.getOrInsert(key, defaultValue) returns the value for the key, or inserts an entry with that default and returns it.
const words = ['we', 'ship', 'code', 'every', 'day', 'we', 'ship'];
const counts = new Map();
const supported = typeof Map.prototype.getOrInsert === 'function';
for (const word of words) {
const current = supported ? counts.getOrInsert(word, 0) : (counts.get(word) ?? 0);
counts.set(word, current + 1);
}
console.log(counts.get('we'), counts.get('code'), counts.size);
2 1 5
For a counter the saving is one lookup, and the fallback above spends the same line getting there. getOrInsertComputed is where the win is larger: it takes a callback that receives the key, and calls it only when the key is missing. That matters for the accumulator shape, where the default is a fresh array and you do not want a new one allocated on every hit.
Side by side, the collapse is the whole point. The classic version is worth knowing because the value is mutated in place rather than reassigned:
const bookings = [['garden', 4], ['library', 12], ['garden', 2]];
const byRoom = new Map();
for (const [room, guests] of bookings) {
if (!byRoom.has(room)) byRoom.set(room, []);
byRoom.get(room).push(guests);
}
function upsertComputed(map, key, make) {
if (typeof map.getOrInsertComputed === 'function') return map.getOrInsertComputed(key, make);
if (!map.has(key)) map.set(key, make());
return map.get(key);
}
const withUpsert = new Map();
for (const [room, guests] of bookings) {
upsertComputed(withUpsert, room, () => []).push(guests);
}
console.log(byRoom.get('garden').join(', '));
console.log([...withUpsert.keys()].join(', '));
4, 2
garden, library
upsertComputed is the fallback the callout asks for: one call where the method exists, has/set/get where it does not.
WeakMap is the same idea with the keys held differently. Its keys must be objects or non-registered symbols, and because a WeakMap does not allow observing the liveness of its keys, those keys are not enumerable and there is no method that returns a list of them:
const metadata = new WeakMap();
const garden = { name: 'garden' };
metadata.set(garden, { lastCleaned: '2026-08-01' });
console.log(metadata.get(garden).lastCleaned);
console.log(metadata.has({ name: 'garden' }));
console.log(Object.keys(garden).join(', '));
console.log(typeof metadata.keys);
try {
new WeakMap().set('garden', 1);
} catch (err) {
console.log(err.constructor.name);
}
2026-08-01
false
name
undefined
TypeError
The metadata is attached to garden without touching it: Object.keys(garden) still reports one property. There is no keys method to call, so there is no way to walk the collection, and a string key is a TypeError rather than a coercion. WeakMap and WeakSet goes into what the weak reference buys you.
Choosing between object, Map and WeakMap
The rule, stated against the three categories from the top of the article.
- Plain object when the set of keys is fixed and known while you are writing the code, and the thing is a record rather than a collection: a config, a response body, an options bag. MDN’s framing is to use an object when there is logic that operates on individual elements, and that is the tell. If you are writing
config.timeoutby name, it is a record. - Map when keys arrive at run time from user input or the network, when the keys are not all strings, when entries are added and removed often, or when you need
sizeand a guaranteed iteration order. MDN’s guidance is to use a map when the keys are unknown until run time and when all the keys are the same type and all the values are the same type, and it states that Map performs better in scenarios involving frequent additions and removals of key-value pairs. - WeakMap when the keys are objects whose lifetime you do not control and the entries should disappear along with them. Metadata about DOM nodes, per-request state keyed on a request object, private data keyed on an instance.
- A null-prototype object, which is a plain object with its prototype removed, when you want an object dictionary but arbitrary strings may show up as keys.
That last case needs the demonstration, because both halves of it are surprising:
const ages = { alice: 18, bob: 27 };
console.log('hasOwnProperty' in ages, Object.hasOwn(ages, 'hasOwnProperty'));
const dictionary = Object.create(null);
dictionary.alice = 18;
console.log('hasOwnProperty' in dictionary, Object.hasOwn(dictionary, 'alice'));
console.log(Object.keys(dictionary).join(', '));
true false
false true
alice
An ordinary object literal answers true to "hasOwnProperty" in ages before you have stored anything under that name, because the check walks up to Object.prototype. The null-prototype version answers false, which is the answer you meant. Object.hasOwn gives the right result on both, and it is recommended over Object.prototype.hasOwnProperty precisely because it works for null-prototype objects and for objects that have overridden the inherited method. It has been available across browsers since March 2022.
The second half is worse, because it does not need a lookup to go wrong:
const ordinary = {};
ordinary['__proto__'] = { hacked: true };
console.log(Object.hasOwn(ordinary, '__proto__'), Object.getPrototypeOf(ordinary).hacked);
const safe = Object.create(null);
safe['__proto__'] = { hacked: true };
console.log(Object.hasOwn(safe, '__proto__'), Object.getPrototypeOf(safe));
false true
true null
Assigning to '__proto__' on an ordinary object reached the accessor inherited from Object.prototype and changed the prototype. No property was stored, and the value went somewhere you did not ask for. On the null-prototype object there is no accessor to reach, so the same assignment created an ordinary own property and left the prototype as null. A Map has the same immunity for a different reason: '__proto__' is a key like any other and its entries were never properties. Prototype methods, objects without __proto__ covers the mechanics.
Map and WeakMap are not objects with better keys. Their entries are not properties at all, which is what the next section is about.
Sending key-value data somewhere else
Every store above is rich inside your program and much poorer the moment it crosses a boundary, and the two boundaries you meet first disagree with each other.
JSON.stringify visits only enumerable own properties:
const rooms = new Map([['garden', 4]]);
const tagged = { name: 'garden', [Symbol('id')]: 7, cleanup: () => {}, notes: undefined };
console.log(JSON.stringify([new Set([1]), new Map([[1, 2]])]));
console.log(JSON.stringify(rooms));
console.log(JSON.stringify(tagged));
console.log(JSON.stringify([undefined, () => {}, 'garden']));
[{},{}]
{}
{"name":"garden"}
[null,null,"garden"]
A Map and a Set both become "{}", because their contents are not properties. The symbol-keyed property vanished, and symbol keys are ignored even when you pass a replacer. undefined, function and symbol values are omitted when found in an object and changed to null when found in an array, which is why the last line lost two entries in place rather than dropping them.
The portable shape for a Map is its entries:
const rooms = new Map([['garden', 4], ['library', 12]]);
const wire = JSON.stringify([...rooms]);
console.log(wire);
const restored = new Map(JSON.parse(wire));
console.log(restored.get('library'), restored.size);
[["garden",4],["library",12]]
12 2
Spreading a Map yields [key, value] arrays, JSON handles arrays of arrays, and the Map constructor reads that form back. Values that are not JSON keep their own problems, and string keys are the only ones that survive the trip.
The structured clone algorithm draws the line somewhere else. It supports Map and Set directly, and it runs whenever you postMessage, write to IndexedDB or call structuredClone:
const rooms = new Map([['garden', { seats: 4 }]]);
const clone = structuredClone(rooms);
console.log(clone instanceof Map, clone.get('garden').seats);
console.log(clone.get('garden') === rooms.get('garden'));
try {
structuredClone({ cleanup: () => {} });
} catch (err) {
console.log(err.name);
}
true 4
false
DataCloneError
The clone is a real Map holding a real copy, so the nested object is a different object. Function objects cannot be duplicated, and one of them anywhere in the graph throws a DataCloneError for the whole call. The prototype chain is not walked or duplicated either, so a class instance comes back as a plain object with its methods gone:
class Room {
constructor(name) {
this.name = name;
}
greet() {
return `welcome to ${this.name}`;
}
}
const clone = structuredClone(new Room('garden'));
console.log(clone.constructor.name, typeof clone.greet, Object.keys(clone).join(', '));
Object undefined name
One last thing, and it is worth knowing where it stands. The Records and Tuples proposal, which would have given JavaScript deeply immutable structures compared by value, was withdrawn at Stage 2, still a draft with nothing shipped, and its repository was archived on 15 April 2025. No value-equality key type exists in the language today, and the successor idea, the Composites proposal championed by Ashley Claymore, is only at Stage 1, so for the foreseeable future the identity rule from the Map section holds: two objects with the same properties are two keys, and if you need them to be one, you build the string that identifies them yourself.
The same ground in order, with the prototype chain and the property internals underneath it, is Part 2: Types, Functions & Classes.
Sources:
- MDN: Keyed collections
- MDN: Property accessors
- MDN: Enumerability and ownership of properties
- MDN: Map
- MDN: WeakMap
- MDN: Object.keys()
- MDN: Object.fromEntries()
- MDN: Object.groupBy()
- MDN: Object.hasOwn()
- MDN: Map.prototype.getOrInsert()
- MDN: Map.prototype.getOrInsertComputed()
- MDN: JSON.stringify()
- MDN: The structured clone algorithm
- TC39 upsert proposal
- TC39 Records and Tuples proposal
Frequently asked questions
What is the difference between an object and a Map in JavaScript?
size, iterates strictly in insertion order, and starts with no inherited keys, while an object inherits from Object.prototype and needs Object.keys(obj).length to be counted. MDN advises reaching for a Map when the keys are not known until run time and all the keys are the same type, and for an object when there is logic that operates on individual elements.Why do two different objects overwrite each other when used as object keys?
"[object Object]". So obj[{id: 1}] and obj[{id: 2}] both write to the single property named "[object Object]", and the second assignment overwrites the first. A Map compares object keys by identity instead, so the two stay separate entries.In what order does Object.keys return keys?
Object.keys({ 100: "a", 2: "b", 7: "c" }) returns ['2', '7', '100']. Symbol keys are never included by Object.keys; Reflect.ownKeys puts them last. A Map does no reordering at all and always iterates in insertion order.Why does JSON.stringify turn my Map into {}?
JSON.stringify visits only enumerable own properties, and a Map keeps its entries in internal slots rather than as properties, so there is nothing for it to visit. JSON.stringify([new Set([1]), new Map([[1, 2]])]) produces '[{},{}]'. Serialize [...map] instead, which gives an array of [key, value] pairs that new Map(parsed) reads straight back.When should I use Object.create(null) instead of a Map?
"hasOwnProperty" in obj is true before you write anything, and assigning to obj["__proto__"] changes the prototype instead of storing a value. A null-prototype object has neither problem, and Object.hasOwn is the membership check to pair it with. If the keys are not all strings, or you need size and ordering guarantees, use a Map.