Map
) ES6
Before ES6, JavaScript didn’t have a data structure for dictionaries and (ab)used objects as dictionaries from strings to arbitrary values. ES6 brought Maps, which are dictionaries from arbitrary values to arbitrary values.
An instance of Map
maps keys to values. A single key-value mapping is called an entry.
There are three common ways of creating Maps.
First, we can use the constructor without any parameters to create an empty Map:
const emptyMap = new Map();
assert.equal(emptyMap.size, 0);
Second, we can pass an iterable (e.g., an Array) over key-value “pairs” (Arrays with two elements) to the constructor:
const map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three'], // trailing comma is ignored
]);
Third, the .set()
method adds entries to a Map and is chainable:
const map = new Map()
.set(1, 'one')
.set(2, 'two')
.set(3, 'three')
;
map.set()
and map.get()
are for writing and reading values (given keys).
const map = new Map();
map.set('foo', 123);
assert.equal(map.get('foo'), 123);
// Unknown key:
assert.equal(map.get('bar'), undefined);
// Use the default value '' if an entry is missing:
assert.equal(map.get('bar') ?? '', '');
map.has()
checks if a Map has an entry with a given key. map.delete()
removes entries.
const map = new Map([['foo', 123]]);
assert.equal(map.has('foo'), true);
assert.equal(map.delete('foo'), true)
assert.equal(map.has('foo'), false)
map.size
contains the number of entries in a Map. map.clear()
removes all entries of a Map.
const map = new Map()
.set('foo', true)
.set('bar', false)
;
assert.equal(map.size, 2)
map.clear();
assert.equal(map.size, 0)
countChars()
returns a Map that maps characters to numbers of occurrences.
function countChars(chars) {
const charCounts = new Map();
for (let ch of chars) {
ch = ch.toLowerCase();
const prevCount = charCounts.get(ch) ?? 0;
charCounts.set(ch, prevCount+1);
}
return charCounts;
}
const result = countChars('AaBccc');
assert.deepEqual(
countChars('AaBccc'),
new Map([
['a', 2],
['b', 1],
['c', 3],
])
);
This is how Maps support iteration:
new Map()
accepts an iterable over key-value pairs. That will be useful later when we look at processing Maps and at converting from and to Maps.
map.keys()
: keys of map
map.values()
: values of map
map.entries()
: entries (key-value pairs) of map
map[Symbol.iterator]()
: same as map.entries()
Map
iterable.
Maps are iterables over key-value pairs. This is a common way of iterating over them:
const map = new Map()
.set(false, 'no')
.set(true, 'yes')
;
for (const [key, value] of map) { // (A)
console.log(JSON.stringify(key) + ' = ' + JSON.stringify(value));
}
In line A, we use destructuring to access the components of the key-value pairs returned by the iterator.
Output:
false = "no"
true = "yes"
The following example loops over the iterable iterator returned by method .keys()
:
for (const key of map.keys()) {
console.log(JSON.stringify(key));
}
Output:
false
true
Maps record in which order entries were created and honor that order when listing keys, values or entries:
const map1 = new Map([
['a', 1],
['b', 2],
]);
for (const key of map1.keys()) {
console.log(JSON.stringify(key));
}
Output:
"a"
"b"
const map2 = new Map([
['b', 2],
['a', 1],
]);
for (const key of map2.keys()) {
console.log(JSON.stringify(key));
}
Output:
"b"
"a"
Maps listing their contents in insertion order has two benefits:
On one hand, the values returned by .keys()
, .values()
and .entries()
are iterables – which enables us to use Array.from()
:
const map = new Map()
.set(false, 'no')
.set(true, 'yes')
;
assert.deepEqual(
Array.from(map.keys()),
[false, true]
);
assert.deepEqual(
Array.from(map.values()),
['no', 'yes']
);
assert.deepEqual(
Array.from(map.entries()),
[
[false, 'no'],
[true, 'yes'],
]
);
On the other hand, the values returned by .keys()
, .values()
and .entries()
are also iterators – which enables us to use the iterator method .toArray()
:
const map = new Map()
.set(false, 'no')
.set(true, 'yes')
;
assert.deepEqual(
map.keys().toArray(),
[false, true]
);
assert.deepEqual(
map.values().toArray(),
['no', 'yes']
);
assert.deepEqual(
map.entries().toArray(),
[
[false, 'no'],
[true, 'yes'],
]
);
As long as a Map only uses strings and symbols as keys, we can convert it to an object (via Object.fromEntries()
):
const map = new Map([
['a', 1],
['b', 2],
]);
const obj = Object.fromEntries(map);
assert.deepEqual(
obj, {a: 1, b: 2}
);
We can also convert an object to a Map with string or symbol keys (via Object.entries()
):
const obj = {
a: 1,
b: 2,
};
const map = new Map(Object.entries(obj));
assert.deepEqual(
map, new Map([['a', 1], ['b', 2]])
);
As we have seen, Maps are iterables over key-value pairs. Therefore, we can use the constructor to create a copy of a Map. That copy is shallow: keys and values are the same; they are not copied/cloned themselves.
const original = new Map()
.set(false, 'no')
.set(true, 'yes')
;
const copy = new Map(original);
assert.deepEqual(original, copy);
There are no methods for combining Maps, which is why we must use a workaround. Let’s combine the following two Maps:
const map1 = new Map()
.set(1, '1a')
.set(2, '1b')
.set(3, '1c')
;
const map2 = new Map()
.set(2, '2b')
.set(3, '2c')
.set(4, '2d')
;
To combine map1
and map2
, we create a new Array and spread (...
) the entries (key-value pairs) of map1
and map2
into it (via iteration). Then we convert the Array back into a Map. All of that is done in line A:
const combinedMap = new Map([...map1, ...map2]); // (A)
assert.deepEqual(
Array.from(combinedMap), // convert to Array for comparison
[
[ 1, '1a' ],
[ 2, '2b' ],
[ 3, '2c' ],
[ 4, '2d' ],
]
);
Exercise: Combining Maps
exercises/maps/combine_maps_test.mjs
We can .map()
and .filter()
an Array, but there are no such operations for a Map. The solution is:
We’ll use the following Map to explore how that works.
const originalMap = new Map()
.set(1, 'a')
.set(2, 'b')
.set(3, 'c')
;
Mapping originalMap
:
const mappedMap = new Map( // step 3
originalMap.entries() // step 1
.map(([k, v]) => [k * 2, '_' + v]) // step 2
);
assert.deepEqual(
mappedMap,
new Map([[2,'_a'], [4,'_b'], [6,'_c']])
);
Filtering originalMap
:
const filteredMap = new Map( // step 3
originalMap.entries() // step 1
.filter(([k, v]) => k < 3) // step 2
);
assert.deepEqual(
filteredMap,
new Map([[1,'a'], [2,'b']])
);
What if we can’t use iterator methods? Then we can switch to Array methods, by
originalMap.entries()
Array.from(originalMap)
.
Any value can be a key, even an object:
const map = new Map();
const KEY1 = {};
const KEY2 = {};
map.set(KEY1, 'hello');
map.set(KEY2, 'world');
assert.equal(map.get(KEY1), 'hello');
assert.equal(map.get(KEY2), 'world');
Most Map operations need to check whether a value is equal to one of the keys. They do so via the internal operation SameValueZero, which works like ===
but considers NaN
to be equal to itself.
As a consequence, we can use NaN
as a key in Maps, just like any other value:
> const map = new Map();
> map.set(NaN, 123);
> map.get(NaN)
123
Different objects are always considered to be different. That is something that can’t be changed (yet – configuring key equality is on TC39’s long-term roadmap).
> new Map().set({}, 1).set({}, 2).size
2
Map
Note: For the sake of conciseness, I’m pretending that all keys have the same type K
and that all values have the same type V
.
new Map()
new Map(entries?)
ES6
new Map<K, V>(
entries?: Iterable<[K, V]>
)
If we don’t provide the parameter entries
, then an empty Map is created. If we do provide an iterable over [key, value] pairs, then those pairs are added as entries to the Map. For example:
const map = new Map([
[ 1, 'one' ],
[ 2, 'two' ],
[ 3, 'three' ], // trailing comma is ignored
]);
Map.*
Map.groupBy(items, computeGroupKey)
ES2024
Map.groupBy<K, T>(
items: Iterable<T>,
computeGroupKey: (item: T, index: number) => K,
): Map<K, Array<T>>;
computeGroupKey
returns a group key for each of the items
.
Map.groupBy()
is a Map where:
assert.deepEqual(
Map.groupBy(
['orange', 'apricot', 'banana', 'apple', 'blueberry'],
(str) => str[0] // compute group key
),
new Map()
.set('o', ['orange'])
.set('a', ['apricot', 'apple'])
.set('b', ['banana', 'blueberry'])
);
Map.prototype.*
: handling single entriesMap.prototype.get(key)
ES6
Returns the value
that key
is mapped to in this Map. If there is no key key
in this Map, undefined
is returned.
const map = new Map([[1, 'one'], [2, 'two']]);
assert.equal(map.get(1), 'one');
assert.equal(map.get(5), undefined);
Map.prototype.set(key, value)
ES6
key
, it is updated. Otherwise, a new entry is created.
this
, which means that we can chain it.
const map = new Map([[1, 'one'], [2, 'two']]);
map.set(1, 'ONE!') // update an existing entry
.set(3, 'THREE!') // create a new entry
;
assert.deepEqual(
Array.from(map.entries()),
[[1, 'ONE!'], [2, 'two'], [3, 'THREE!']]
);
Map.prototype.has(key)
ES6
Returns whether the given key exists in this Map.
const map = new Map([[1, 'one'], [2, 'two']]);
assert.equal(map.has(1), true); // key exists
assert.equal(map.has(5), false); // key does not exist
Map.prototype.delete(key)
ES6
If there is an entry whose key is key
, it is removed and true
is returned. Otherwise, nothing happens and false
is returned.
const map = new Map([[1, 'one'], [2, 'two']]);
assert.equal(map.delete(1), true);
assert.equal(map.delete(5), false); // nothing happens
assert.deepEqual(
Array.from(map.entries()),
[[2, 'two']]
);
Map.prototype
: handling all entriesget Map.prototype.size
ES6
Returns how many entries this Map has.
const map = new Map([[1, 'one'], [2, 'two']]);
assert.equal(map.size, 2);
Map.prototype.clear()
ES6
Removes all entries from this Map.
const map = new Map([[1, 'one'], [2, 'two']]);
assert.equal(map.size, 2);
map.clear();
assert.equal(map.size, 0);
Map.prototype
: iterating and loopingBoth iterating and looping happen in the order in which entries were added to a Map.
Map.prototype.entries()
ES6
Returns an iterable with one [key, value] pair for each entry in this Map. The pairs are Arrays of length 2.
const map = new Map([[1, 'one'], [2, 'two']]);
for (const entry of map.entries()) {
console.log(entry);
}
Output:
[ 1, 'one' ]
[ 2, 'two' ]
Map.prototype.forEach(callback, thisArg?)
ES6
Map.prototype.forEach(
callback: (value: V, key: K, theMap: Map<K,V>) => void,
thisArg?: any
): void
thisArg
is provided, this
is set to it for each invocation. Otherwise, this
is set to undefined
.
const map = new Map([[1, 'one'], [2, 'two']]);
map.forEach((value, key) => console.log(value, key));
Output:
one 1
two 2
Map.prototype.keys()
ES6
Returns an iterable over all keys in this Map.
const map = new Map([[1, 'one'], [2, 'two']]);
for (const key of map.keys()) {
console.log(key);
}
Output:
1
2
Map.prototype.values()
ES6
Returns an iterable over all values in this Map.
const map = new Map([[1, 'one'], [2, 'two']]);
for (const value of map.values()) {
console.log(value);
}
Output:
one
two
Map.prototype[Symbol.iterator]()
ES6
The default way of iterating over Maps. Same as map.entries()
.
const map = new Map([[1, 'one'], [2, 'two']]);
for (const [key, value] of map) {
console.log(key, value);
}
Output:
1 one
2 two
If we need a dictionary-like data structure with keys that are neither strings nor symbols, we have no choice: we must use a Map.
If, however, our keys are either strings or symbols, we must decide whether or not to use an object. A rough general guideline is:
Is there a fixed set of keys (known at development time)?
Then use an object obj
and access the values via fixed keys:
const value = obj.key;
Can the set of keys change at runtime?
Then use a Map map
and access the values via keys stored in variables:
const theKey = 123;
map.get(theKey);
We normally want Map keys to be compared by value (two keys are considered equal if they have the same content). That excludes objects. However, there is one use case for objects as keys: externally attaching data to objects. But that use case is served better by WeakMaps, where entries don’t prevent keys from being garbage-collected (for details, see the next chapter).
In principle, Maps entries are unordered. The main reason for ordering entries is so that operations that list entries, keys, or values are deterministic. That helps, for example, with testing.
.size
, while Arrays have a .length
?In JavaScript, indexable sequences (such as Arrays and strings) have a .length
, while unindexed collections (such as Maps and Sets) have a .size
:
.length
is based on indices; it is always the highest index plus one.
.size
counts the number of elements in a collection.