Modern JavaScript: Maps
Welcome to the Maps lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
A JavaScript object's property keys can be strings, numbers, or symbols.
>
const aString = 'aString';const aNumber = 5;const aSymbol = Symbol('aSymbol');const obj = {[aString]: 1,[aNumber]: 2,[aSymbol]: 3,};[obj[aString], obj[aNumber], obj[aSymbol]];Result:
[1, 2, 3]
If we try to use any other value as a key, that value is automatically converted into a string.
>
const obj = {[undefined]: 1,};Object.keys(obj);Result:
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
obj['undefined'];Result:
1
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
obj[undefined];Result:
1
Note that the last two examples look different, but give identical results! The
undefinedvalue becomes a string when used as an object key. When we writeobj[undefined], it's as if we'd writtenobj['undefined'].Similar problems happen when we try to use arrays, objects, or
nullas property keys. JavaScript coerces those values into strings, leading to potentially confusing bugs.>
const msFluff = {name: 'Ms. Fluff'};const catCoats = {};/* Property keys must be strings. We're using the `msFluff` object as a* key in another object, so it gets converted into a string.* Unfortunately, that string is '[object Object]'. */catCoats[msFluff] = 'calico';Object.keys(catCoats);Result:
Fortunately, JavaScript gives us a way to address this limitation. The
Mapdata type maps keys to values, like objects do. But a map's keys can be anything: arrays, objects, functions, other maps, etc.We start by creating an empty map.
>
const userEmails = new Map();We use
.set(key, value)to assign a value to a key. Then, we can use.get(key)to retrieve the value at that key.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
userEmails.set('Amir', 'amir@example.com');userEmails.get('Amir');Result:
'amir@example.com'
Unlike with objects and arrays, we can't do
someMap[someKey]. JavaScript will happily return a value (probablyundefined) but it won't be what we want!We can give a map some initial values by passing an array of two-element arrays. Each two-element array specifies a key and a value.
>
const userEmails = new Map([['Amir', 'amir@example.com'],['Betty', 'betty@example.com']]);userEmails.get('Betty');Result:
'betty@example.com'
Regular JavaScript objects can only have one value at a given key. When we give a key a new value, it overwrites the old value.
>
const emails = {};emails['Betty'] = 'betty.j@example.com';emails['Betty'] = 'betty.k@example.com';emails['Betty'];Result:
'betty.k@example.com'
Maps work in the same way:
.setting a key overwrites any current value at that key.>
const emails = new Map();emails.set('Betty', 'betty.j@example.com');emails.set('Betty', 'betty.k@example.com');emails.get('Betty');Result:
'betty.k@example.com'
We can use
.hasto check for whether a certain key exists on a map, and we can.deleteany key.>
const emails = new Map([['Amir', 'amir@example.com'],['Betty', 'betty@example.com']]);emails.delete('Amir');[emails.has('Amir'), emails.has('Betty')];Result:
[false, true]
We can also use
.clearto remove every key in the map.>
const emails = new Map();emails.set('Cindy', 'cindy@example.com');emails.clear();emails.set('Dalili', 'dalili@example.com');[emails.has('Cindy'), emails.has('Dalili')];Result:
[false, true]
When we try to
.geta key that doesn't exist, maps respond like objects do: they returnundefined.>
const emails = {Amir: 'amir@example.com'};emails['Betty'];Result:
undefined
>
const emails = new Map();emails.set('Amir', 'amir@example.com');emails.get('Betty');Result:
undefined
Maps have a
.sizeproperty that returns the number of items (the number of keys) in the map.>
const emails = new Map();emails.set('Betty', 'betty.j@example.com');emails.size;Result:
1
By contrast, plain objects don't have a built-in way to retrieve their size. To get the count of keys in an object, we have to do more work, like
Object.keys(someObject).length.>
const emails = {'Betty': 'betty.j@example.com',};Object.keys(emails).length;Result:
1
Maps' sizes respect the fact that each key can only exist once. When we set a new value for an existing key, the number of keys doesn't change, so the map's size also doesn't change.
>
const emails = new Map();emails.set('Betty', 'betty.j@example.com');emails.set('Betty', 'betty.k@example.com');emails.size;Result:
1
A map's keys don't have to be strings. They can be anything: arrays, objects, or even
nullandundefined.Now let's see what maps are good for. Imagine that we're building a social network where users can follow each other. We can use a map to remember each user's followers. Each user's username becomes a key in the map. The value at that key is an array of other users who follow the first user.
(We could also use the user objects themselves as the map keys, mapping each user object to an array of their followers. That works just fine with maps, but isn't possible with a regular object. But to keep the code short, we'll work with usernames instead of full user objects.)
For example, Amir and Cindy might both follow Betty:
>
const follows = new Map();follows.set('betty', ['amir', 'cindy']);To decide whether Cindy follows Betty, we
.getBetty from the map. That gives us an array of Betty's followers. Then we can check for whether the array.includesCindy.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
follows.get('betty').includes('cindy');Result:
true
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
follows.get('betty').includes('dalili');Result:
false
To add a follower, we
.getthe first user's follower array, then.pushthe follower onto it. When we.getthe user's follower array again later, we'll see that newly-added follower.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
follows.get('betty').push('dalili');follows.get('betty').includes('dalili');Result:
true
What if a user tries to follow someone that doesn't have any followers yet? For example, what if Betty decides to follow Dalili back? We'll get an error, since the
'dalili'key doesn't exist in the map, giving usundefinedback rather than an array. (You can typeerrorwhen a code example will throw an error.)- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
follows.get('dalili').push('betty');Result:
TypeError: Cannot read properties of undefined (reading 'push')
We can guard against this by checking for whether the follows map
.hasan entry for this user. If there's no entry, we.setan empty array for that user. We use.sethere because we're not modifying an existing array; we're inserting a new array where nothing existed before.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
if (!follows.has('dalili')) {follows.set('dalili', []);}follows.get('dalili').push('betty');follows.get('dalili').includes('betty');Result:
true
We now know how to check a user's followers, and how to add followers. We can use this knowledge to model a whole social network!
Here's a code problem:
Write a
SocialGraphclass with a constructor and two methods.- The constructor creates an empty
Map. - The
addFollowmethod records thatfollowerfollowsfollowedby updating the map. Note that order matters here! (If Amir follows Betty, that doesn't mean that Betty follows Amir.) - The
followsmethod checks for whetherfollowerfollowsfollowed, returning a boolean.
These notes may help:
- You can use the array's
includesmethod to check for whether a user is in another user's array of followers. - Sometimes the
followsmethod is called with afolloweduser who has no followers, so they won't have an entry in the map yet. You'll need to handle that case and returnfalse.
class SocialGraph {constructor() {this.map = new Map();}addFollow(follower, followed) {if (!this.map.has(followed)) {this.map.set(followed, []);}/* We store one array per followed user. It's also possible to solve this* problem "backwards", by storing one array per following user. Either* type of solution works. */this.map.get(followed).push(follower);}follows(follower, followed) {if (!this.map.has(followed)) {return false;} else {return this.map.get(followed).includes(follower);}}}const socialGraph = new SocialGraph();socialGraph.addFollow('amir', 'betty');socialGraph.addFollow('amir', 'cindy');socialGraph.addFollow('betty', 'cindy');[socialGraph.follows('amir', 'betty'),socialGraph.follows('amir', 'cindy'),socialGraph.follows('betty', 'amir'),socialGraph.follows('betty', 'cindy'),socialGraph.follows('cindy', 'amir'),socialGraph.follows('cindy', 'betty'),];- Goal:
[true, true, false, true, false, false]
- Yours:
[true, true, false, true, false, false]
- The constructor creates an empty
Some final notes about terminology. The
Mapdata type stores a mapping from keys to values. The.mapmethod on arrays builds a new array where each element is replaced with ("mapped to") a new value:>
[1, 2, 3].map(n => 2 * n);Result:
The data type
Mapand the method.mapare related at the conceptual level: they both "map" (or "relate") things to other things. Other than that, they have no relationship.Terminology for maps varies by language. Most languages don't distinguish between maps and what JavaScript calls "objects". Instead, they have a single data type similar to JavaScript's maps.
This data type is called "map" in JavaScript and Clojure; "dictionary" in Python and C#; and "hash" in Perl and Ruby. Fortunately, these data types all work in a similar way regardless of their names!