Beyond Objects and Arrays: Maps and Sets in ES6

If you've been writing JavaScript for a while, you're probably very familiar with objects and arrays. These two built-in data structures have been the workhorses of JavaScript since its inception. But with ES6 (ECMAScript 2015), JavaScript introduced two new collection types: Maps and Sets.

In this blog post, I'll show you how Maps and Sets work, when to use them instead of objects and arrays, and some practical examples of how they can make your code cleaner and more efficient.

A brief history of JavaScript collections

Before ES6, JavaScript developers were limited to using Objects for key-value mappings and Arrays for ordered lists. While these structures are incredibly versatile, they come with limitations:

  • Object keys can only be strings or symbols
  • Objects don't maintain insertion order
  • Arrays are inefficient for checking if an item exists
  • Deleting items from arrays is cumbersome
  • There was no built-in way to create a collection of unique values

These pain points led to the addition of Map and Set in ES6, bringing JavaScript more in line with collection APIs in other languages.

Maps: Better key-value collections

A Map is a collection of key-value pairs, similar to an Object. However, it offers several advantages:

// Creating a new Map
const userRoles = new Map();

// Adding key-value pairs
userRoles.set('john', 'admin');
userRoles.set('sarah', 'editor');
userRoles.set('mike', 'subscriber');

// Alternatively, initialize with an array of key-value pairs
const userRoles = new Map([
  ['john', 'admin'],
  ['sarah', 'editor'],
  ['mike', 'subscriber']
]);

// Getting a value
console.log(userRoles.get('john')); // 'admin'

// Checking if a key exists
console.log(userRoles.has('sarah')); // true

// Getting the size
console.log(userRoles.size); // 3

// Deleting an entry
userRoles.delete('mike');
console.log(userRoles.size); // 2

// Clearing the entire Map
userRoles.clear();
console.log(userRoles.size); // 0

Key advantages of Maps over Objects

  1. Any value can be a key - Unlike Objects, which limit keys to strings and symbols, Maps can use any value as a key, including objects, functions, and primitive values:
const userMap = new Map();

// Using an object as a key
const userObject = { id: 1, name: 'John' };
userMap.set(userObject, { role: 'admin', lastLogin: '2023-05-17' });

// Using a function as a key
function sayHello() { console.log('Hello!'); }
userMap.set(sayHello, 'This is a greeting function');

// Retrieving values
console.log(userMap.get(userObject)); // { role: 'admin', lastLogin: '2023-05-17' }
console.log(userMap.get(sayHello)); // 'This is a greeting function'
  1. Maps maintain insertion order - When you iterate over a Map, the entries are returned in the order they were inserted:
const fruitInventory = new Map([
  ['apples', 150],
  ['oranges', 75],
  ['bananas', 200]
]);

// Iterating over a Map maintains insertion order
for (const [fruit, count] of fruitInventory) {
  console.log(`${fruit}: ${count}`);
}
// Output:
// apples: 150
// oranges: 75
// bananas: 200
  1. Better performance for frequent additions and removals - Maps are optimized for frequent additions and removals of key-value pairs.
  2. Built-in iteration methods - Maps come with several handy methods for iteration:
const userRoles = new Map([
  ['john', 'admin'],
  ['sarah', 'editor'],
  ['mike', 'subscriber']
]);

// keys() returns an iterator for the keys
for (const user of userRoles.keys()) {
  console.log(user);
}
// Output: john, sarah, mike

// values() returns an iterator for the values
for (const role of userRoles.values()) {
  console.log(role);
}
// Output: admin, editor, subscriber

// entries() returns an iterator for [key, value] pairs
for (const [user, role] of userRoles.entries()) {
  console.log(`${user} is a ${role}`);
}
// Output: 
// john is a admin
// sarah is a editor
// mike is a subscriber

// forEach provides a callback-based iteration
userRoles.forEach((role, user) => {
  console.log(`${user}: ${role}`);
});
// Output:
// john: admin
// sarah: editor
// mike: subscriber

Sets: Collections of unique values

A Set is a collection of unique values, similar to an array without duplicates. It's perfect for when you need to track unique items or check for presence quickly.

// Creating a new Set
const uniqueVisitors = new Set();

// Adding values
uniqueVisitors.add('user123');
uniqueVisitors.add('user456');
uniqueVisitors.add('user789');
uniqueVisitors.add('user123'); // Duplicate - will be ignored

// Alternatively, initialize with an array
const uniqueVisitors = new Set(['user123', 'user456', 'user789', 'user123']);

// Check size
console.log(uniqueVisitors.size); // 3, not 4, because 'user123' is a duplicate

// Check if a value exists
console.log(uniqueVisitors.has('user456')); // true

// Deleting a value
uniqueVisitors.delete('user456');
console.log(uniqueVisitors.has('user456')); // false

// Clearing the entire Set
uniqueVisitors.clear();
console.log(uniqueVisitors.size); // 0

Key advantages of Sets over Arrays

  1. Automatic duplicate removal - Sets automatically ensure that all values are unique:
// Removing duplicates from an array
const messyArray = [1, 2, 3, 1, 4, 2, 5];
const uniqueValues = [...new Set(messyArray)];
console.log(uniqueValues); // [1, 2, 3, 4, 5]
  1. Fast lookup for presence check - Checking if a value exists in a Set is much faster than checking an Array, especially for large collections:
// With an Array
const largeArray = [/* imagine thousands of items */];
const hasValue = largeArray.includes('needle'); // O(n) - must search each item

// With a Set
const largeSet = new Set([/* imagine thousands of items */]);
const hasValue = largeSet.has('needle'); // O(1) - constant time lookup
  1. Easier deletion of specific values - With arrays, you need to find the index first, then splice, whereas Sets have a simple delete method:
// Removing a specific value from an Array
const index = myArray.indexOf('value-to-remove');
if (index > -1) {
  myArray.splice(index, 1);
}

// Removing a specific value from a Set
mySet.delete('value-to-remove'); // Returns true if the value was in the set
  1. Iteration in insertion order - Like Maps, Sets maintain insertion order when iterating:
const rainbow = new Set(['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']);

for (const color of rainbow) {
  console.log(color);
}
// Output: red, orange, yellow, green, blue, indigo, violet

Practical use cases for Maps and Sets

Maps for caching results

Maps are excellent for caching function results, especially when using objects as keys:

// A cache for expensive calculations
const calculationCache = new Map();

function expensiveCalculation(obj) {
  // If we've seen this exact object before, return the cached result
  if (calculationCache.has(obj)) {
    console.log('Cache hit!');
    return calculationCache.get(obj);
  }
  
  console.log('Cache miss! Calculating...');
  // Simulate an expensive operation
  const result = /* complex calculation using obj */;
  
  // Store in cache for future use
  calculationCache.set(obj, result);
  return result;
}

Maps for storing metadata

Maps are great for associating metadata with DOM elements or other objects without modifying them:

const elementData = new Map();

// Store data associated with DOM elements
document.querySelectorAll('.interactive').forEach(element => {
  elementData.set(element, {
    clickCount: 0,
    lastInteraction: null,
    initialState: element.textContent
  });
});

// Later, when events happen...
element.addEventListener('click', e => {
  const data = elementData.get(e.currentTarget);
  data.clickCount++;
  data.lastInteraction = new Date();
  console.log(`Element clicked ${data.clickCount} times`);
});

Sets for tracking unique items

Sets are perfect for maintaining lists of unique identifiers or values:

// Track users who have completed an action
const completedUsers = new Set();

function userCompletedAction(userId) {
  completedUsers.add(userId);
  console.log(`${completedUsers.size} users have completed the action`);
}

function hasUserCompletedAction(userId) {
  return completedUsers.has(userId);
}

Sets for efficient difference, union, and intersection operations

Sets make it easy to perform common mathematical set operations:

// Set operations
const set1 = new Set([1, 2, 3, 4, 5]);
const set2 = new Set([3, 4, 5, 6, 7]);

// Union: combine sets (unique values from both)
const union = new Set([...set1, ...set2]);
console.log([...union]); // [1, 2, 3, 4, 5, 6, 7]

// Intersection: values common to both sets
const intersection = new Set([...set1].filter(x => set2.has(x)));
console.log([...intersection]); // [3, 4, 5]

// Difference: values in set1 but not in set2
const difference = new Set([...set1].filter(x => !set2.has(x)));
console.log([...difference]); // [1, 2]

Converting between Maps/Sets and Arrays/Objects

Sometimes you need to convert between these collection types:

Converting between Maps and Objects

// Object to Map
const userRolesObj = {
  john: 'admin',
  sarah: 'editor',
  mike: 'subscriber'
};

const userRolesMap = new Map(Object.entries(userRolesObj));

// Map to Object
const userRolesMapBack = Object.fromEntries(userRolesMap);
console.log(userRolesMapBack); // {john: 'admin', sarah: 'editor', mike: 'subscriber'}

Converting between Sets and Arrays

// Array to Set
const fruitArray = ['apple', 'banana', 'orange', 'apple', 'pear'];
const fruitSet = new Set(fruitArray);

// Set to Array
const uniqueFruits = [...fruitSet];
console.log(uniqueFruits); // ['apple', 'banana', 'orange', 'pear']

// Alternative way to convert Set to Array
const alsoUniqueFruits = Array.from(fruitSet);

Browser Support and Polyfills

Maps and Sets are supported in all modern browsers, but if you need to support older browsers like IE11, you'll need a polyfill. If you're using a transpiler like Babel, the core-js library can provide polyfills for Map and Set.

// With core-js imported or as part of your Babel setup
import 'core-js/features/map';
import 'core-js/features/set';

Performance Considerations

While Maps and Sets offer many advantages, there are a few performance considerations to keep in mind:

  1. Memory usage: Maps and Sets generally use more memory than plain objects and arrays for small collections, but their performance benefits for lookups and insertions/deletions often outweigh this cost.
  2. Serialization: Unlike Objects, Maps and Sets don't have native JSON serialization. If you need to serialize a Map or Set, you'll need to convert it to an array or object first:
// Serializing a Map
const serializedMap = JSON.stringify([...myMap]);

// Deserializing back to a Map
const deserializedMap = new Map(JSON.parse(serializedMap));

Conclusion

Maps and Sets are powerful additions to JavaScript's collection types. They solve specific problems that were awkward to handle with just objects and arrays, and they make certain operations much more elegant and efficient.

As a rule of thumb:

  • Use Map when you need a key-value collection, especially if keys aren't just strings or if order matters
  • Use Set when you need a collection of unique values with fast lookups
  • Stick with Object when you just need a simple string-keyed dictionary or when JSON serialization is important
  • Use Array when you need an ordered collection that might contain duplicates or when index access is important

I hope this guide helps you make better use of Maps and Sets in your JavaScript applications! If you've found any other interesting use cases for these collection types, I'd love to hear about them in the comments.