JavaScript Essentials

Sets & Maps

45 min Lesson 38 of 60

Introduction to Sets and Maps

ES6 introduced two powerful collection types: Set and Map. These data structures fill important gaps that plain objects and arrays cannot address efficiently. A Set is a collection of unique values where no duplicates are allowed. A Map is a collection of key-value pairs where keys can be any type, not just strings. Both provide superior performance characteristics for their intended use cases compared to arrays and objects, particularly for operations like membership testing, deduplication, and key-based lookups with non-string keys.

Before Sets and Maps, developers had to use arrays with indexOf() for uniqueness checks and plain objects for key-value storage. These workarounds had significant limitations: arrays required O(n) time for membership checks, and object keys were always coerced to strings, meaning obj[1] and obj["1"] referred to the same property. Sets and Maps solve both of these problems with dedicated, optimized implementations.

Set: Creating and Basic Operations

A Set stores unique values of any type. You can create a Set from an iterable (like an array) or build one up incrementally using the add() method. The Set automatically handles uniqueness -- if you try to add a value that already exists, the Set simply ignores the duplicate.

Example: Creating Sets

// Creating an empty Set
const emptySet = new Set();

// Creating a Set from an array
const numbers = new Set([1, 2, 3, 4, 5]);
console.log(numbers); // Set(5) {1, 2, 3, 4, 5}

// Duplicates are automatically removed
const withDupes = new Set([1, 2, 2, 3, 3, 3, 4]);
console.log(withDupes);      // Set(4) {1, 2, 3, 4}
console.log(withDupes.size); // 4

// Creating from a string (each character becomes an element)
const chars = new Set('hello');
console.log(chars); // Set(4) {'h', 'e', 'l', 'o'}

// Creating from any iterable
function* fibonacci() {
    let a = 0, b = 1;
    while (a < 20) {
        yield a;
        [a, b] = [b, a + b];
    }
}
const fibSet = new Set(fibonacci());
console.log(fibSet); // Set(8) {0, 1, 1, 2, 3, 5, 8, 13} -- wait, 1 appears once
// Actually: Set(8) {0, 1, 2, 3, 5, 8, 13} -- duplicate 1 is removed

add(), delete(), has(), size, and clear()

Sets provide a straightforward API for managing their contents. The add() method returns the Set itself, enabling method chaining. The has() method performs lookups in O(1) average time, which is dramatically faster than an array's includes() method for large collections.

Example: Set Core Methods

const tags = new Set();

// add() returns the Set, so you can chain
tags.add('javascript')
    .add('typescript')
    .add('react')
    .add('nodejs')
    .add('javascript'); // duplicate -- ignored silently

console.log(tags.size); // 4

// has() checks membership in O(1) time
console.log(tags.has('react'));   // true
console.log(tags.has('angular')); // false

// delete() removes a value and returns true/false
console.log(tags.delete('react')); // true (was found and removed)
console.log(tags.delete('react')); // false (not found)
console.log(tags.size);            // 3

// clear() removes all elements
tags.clear();
console.log(tags.size); // 0

// Sets use the SameValueZero algorithm for equality
const mixedSet = new Set();
mixedSet.add(0);
mixedSet.add(-0);    // 0 and -0 are considered equal
mixedSet.add(NaN);
mixedSet.add(NaN);   // NaN is considered equal to itself in Sets
console.log(mixedSet.size); // 2 (just 0 and NaN)
console.log(mixedSet.has(NaN)); // true -- unlike === where NaN !== NaN
Note: Sets use the SameValueZero comparison algorithm, which is similar to strict equality (===) but with one important difference: NaN is considered equal to NaN. This means you can reliably store and check for NaN in a Set, unlike with array methods that use ===. Also, 0 and -0 are treated as the same value.

Iterating Over Sets

Sets maintain insertion order, meaning elements are iterated in the order they were first added. Sets are iterable by default and support several iteration methods.

Example: Iterating Sets

const frameworks = new Set(['React', 'Vue', 'Angular', 'Svelte']);

// for...of loop (most common)
for (const framework of frameworks) {
    console.log(framework);
}
// "React", "Vue", "Angular", "Svelte"

// forEach method
frameworks.forEach((value, valueAgain, set) => {
    console.log(`${value} (${valueAgain})`);
    // Note: value and valueAgain are the same!
    // This signature matches Map.forEach for consistency
});

// values() iterator
const valuesIter = frameworks.values();
console.log(valuesIter.next()); // { value: 'React', done: false }
console.log(valuesIter.next()); // { value: 'Vue', done: false }

// keys() is an alias for values() in Sets
console.log(frameworks.keys === frameworks.values); // depends on implementation
// Both return the same iterator

// entries() returns [value, value] pairs (for Map compatibility)
for (const entry of frameworks.entries()) {
    console.log(entry); // ['React', 'React'], ['Vue', 'Vue'], etc.
}

// Spread operator works because Sets are iterable
const frameworkArray = [...frameworks];
console.log(frameworkArray); // ['React', 'Vue', 'Angular', 'Svelte']

// Destructuring works too
const [first, second, ...rest] = frameworks;
console.log(first);  // "React"
console.log(second); // "Vue"
console.log(rest);   // ["Angular", "Svelte"]

Set Operations: Union, Intersection, Difference, and Symmetric Difference

While JavaScript Sets do not have built-in methods for set operations like union and intersection (though proposals are in progress), implementing them is straightforward. These operations are fundamental in mathematics and extremely useful in programming for combining, comparing, and filtering collections of data.

Example: Set Operations

const setA = new Set([1, 2, 3, 4, 5]);
const setB = new Set([4, 5, 6, 7, 8]);

// Union: all elements from both sets
function union(a, b) {
    return new Set([...a, ...b]);
}
console.log(union(setA, setB));
// Set(8) {1, 2, 3, 4, 5, 6, 7, 8}

// Intersection: elements common to both sets
function intersection(a, b) {
    return new Set([...a].filter(x => b.has(x)));
}
console.log(intersection(setA, setB));
// Set(2) {4, 5}

// Difference: elements in A but not in B
function difference(a, b) {
    return new Set([...a].filter(x => !b.has(x)));
}
console.log(difference(setA, setB));
// Set(3) {1, 2, 3}

// Symmetric Difference: elements in either set but not both
function symmetricDifference(a, b) {
    const diff = new Set(a);
    for (const elem of b) {
        if (diff.has(elem)) {
            diff.delete(elem);
        } else {
            diff.add(elem);
        }
    }
    return diff;
}
console.log(symmetricDifference(setA, setB));
// Set(6) {1, 2, 3, 6, 7, 8}

// Subset check: is A a subset of B?
function isSubset(a, b) {
    return [...a].every(x => b.has(x));
}
console.log(isSubset(new Set([4, 5]), setB));     // true
console.log(isSubset(new Set([1, 4, 5]), setB));  // false

// Superset check: does A contain all of B?
function isSuperset(a, b) {
    return [...b].every(x => a.has(x));
}
console.log(isSuperset(setA, new Set([1, 3]))); // true
Pro Tip: As of recent ECMAScript proposals, native Set methods like .union(), .intersection(), .difference(), .symmetricDifference(), .isSubsetOf(), .isSupersetOf(), and .isDisjointFrom() are being added to the language. Check browser compatibility before using them, but they are available in recent versions of major browsers and Node.js. These native methods are more efficient than the manual implementations shown above.

Converting Between Set and Array

Sets and Arrays can be easily converted back and forth. This interoperability is one of the most practical aspects of Sets, as it allows you to use Set for deduplication and then convert back to an Array for further processing with array methods like map(), filter(), and reduce().

Example: Set-Array Conversions

// Array to Set
const arr = [1, 2, 3, 2, 1, 4, 5, 4];
const uniqueSet = new Set(arr);

// Set to Array -- three approaches
const way1 = [...uniqueSet];           // Spread operator (most common)
const way2 = Array.from(uniqueSet);    // Array.from()
const way3 = Array.from(uniqueSet.values()); // From iterator

console.log(way1); // [1, 2, 3, 4, 5]
console.log(way2); // [1, 2, 3, 4, 5]
console.log(way3); // [1, 2, 3, 4, 5]

// One-liner deduplication
const unique = [...new Set([5, 3, 5, 2, 1, 3, 2, 4])];
console.log(unique); // [5, 3, 2, 1, 4] -- preserves first occurrence order

// Deduplication with transformation
const words = ['Hello', 'HELLO', 'hello', 'World', 'world'];
const uniqueWords = [...new Set(words.map(w => w.toLowerCase()))];
console.log(uniqueWords); // ['hello', 'world']

Deduplication Patterns with Set

One of the most common and practical uses of Set is removing duplicates from arrays. This pattern appears constantly in real-world applications: removing duplicate API results, ensuring unique user selections, filtering redundant event listeners, and more.

Example: Real-World Deduplication Patterns

// Deduplicate an array of objects by a specific property
function uniqueBy(array, keyFn) {
    const seen = new Set();
    return array.filter(item => {
        const key = keyFn(item);
        if (seen.has(key)) return false;
        seen.add(key);
        return true;
    });
}

const users = [
    { id: 1, name: 'Alice', dept: 'Engineering' },
    { id: 2, name: 'Bob', dept: 'Marketing' },
    { id: 1, name: 'Alice', dept: 'Engineering' }, // duplicate
    { id: 3, name: 'Charlie', dept: 'Engineering' },
    { id: 2, name: 'Bob', dept: 'Marketing' }      // duplicate
];

const uniqueUsers = uniqueBy(users, user => user.id);
console.log(uniqueUsers);
// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }]

// Find duplicates in an array
function findDuplicates(array) {
    const seen = new Set();
    const duplicates = new Set();
    for (const item of array) {
        if (seen.has(item)) {
            duplicates.add(item);
        }
        seen.add(item);
    }
    return [...duplicates];
}

console.log(findDuplicates([1, 2, 3, 2, 4, 5, 1])); // [2, 1]

// Track unique visitors
class UniqueVisitorTracker {
    constructor() {
        this.visitors = new Set();
    }

    recordVisit(userId) {
        const isNew = !this.visitors.has(userId);
        this.visitors.add(userId);
        return isNew;
    }

    get uniqueCount() {
        return this.visitors.size;
    }

    hasVisited(userId) {
        return this.visitors.has(userId);
    }
}

const tracker = new UniqueVisitorTracker();
console.log(tracker.recordVisit('user_1')); // true (new visitor)
console.log(tracker.recordVisit('user_2')); // true
console.log(tracker.recordVisit('user_1')); // false (returning visitor)
console.log(tracker.uniqueCount);            // 2
Common Mistake: Sets compare objects by reference, not by value. Two objects with identical properties are considered different unless they are the exact same object in memory. new Set([{a: 1}, {a: 1}]) will have size 2 because those are two different object references. If you need to deduplicate objects by their content, use the uniqueBy() pattern shown above with a key function.

Map: Creation and Basic Operations

A Map is an ordered collection of key-value pairs where keys can be of any type -- objects, functions, numbers, or even NaN. Unlike plain objects where keys are always strings or Symbols, Maps preserve the original type of the key. Maps also maintain insertion order and provide a size property for efficient counting.

Example: Creating Maps

// Creating an empty Map
const emptyMap = new Map();

// Creating a Map from an array of [key, value] pairs
const userRoles = new Map([
    ['alice', 'admin'],
    ['bob', 'editor'],
    ['charlie', 'viewer']
]);

console.log(userRoles); // Map(3) {'alice' => 'admin', 'bob' => 'editor', 'charlie' => 'viewer'}
console.log(userRoles.size); // 3

// Creating from Object.entries()
const config = { theme: 'dark', lang: 'en', fontSize: 16 };
const configMap = new Map(Object.entries(config));
console.log(configMap.get('theme')); // "dark"

// Maps can have any type as keys
const objectKeys = new Map();
const keyObj = { id: 1 };
const keyArr = [1, 2, 3];
const keyFn = () => 'hello';

objectKeys.set(keyObj, 'Object value');
objectKeys.set(keyArr, 'Array value');
objectKeys.set(keyFn, 'Function value');
objectKeys.set(42, 'Number value');
objectKeys.set(true, 'Boolean value');
objectKeys.set(null, 'Null value');
objectKeys.set(undefined, 'Undefined value');

console.log(objectKeys.get(keyObj));    // "Object value"
console.log(objectKeys.get(42));        // "Number value"
console.log(objectKeys.get(null));      // "Null value"
console.log(objectKeys.size);           // 7

set(), get(), has(), delete(), size, and clear()

The Map API is intuitive and consistent. Like Set, the set() method returns the Map itself for chaining. All key lookup operations are O(1) average time, making Maps highly efficient for key-value storage.

Example: Map Core Methods

const inventory = new Map();

// set() returns the Map for chaining
inventory
    .set('laptop', { price: 999, stock: 50 })
    .set('phone', { price: 699, stock: 120 })
    .set('tablet', { price: 449, stock: 75 })
    .set('headphones', { price: 199, stock: 200 });

console.log(inventory.size); // 4

// get() retrieves the value for a key
const laptop = inventory.get('laptop');
console.log(laptop.price); // 999
console.log(laptop.stock); // 50

// get() returns undefined for missing keys
console.log(inventory.get('desktop')); // undefined

// has() checks if a key exists
console.log(inventory.has('phone'));   // true
console.log(inventory.has('desktop')); // false

// Updating a value (just set again with the same key)
inventory.set('laptop', { price: 899, stock: 45 });
console.log(inventory.get('laptop').price); // 899

// delete() removes a key-value pair
console.log(inventory.delete('headphones')); // true
console.log(inventory.delete('headphones')); // false (already removed)
console.log(inventory.size);                  // 3

// clear() removes all entries
const temp = new Map([['a', 1], ['b', 2]]);
temp.clear();
console.log(temp.size); // 0

Iterating Over Maps

Maps are iterable and maintain insertion order. They provide three iterator methods: keys(), values(), and entries(). The default iterator for Maps is entries(), meaning a for...of loop gives you [key, value] pairs that you can destructure directly.

Example: Iterating Maps

const scores = new Map([
    ['Alice', 95],
    ['Bob', 87],
    ['Charlie', 92],
    ['Diana', 98]
]);

// for...of with destructuring (most common pattern)
for (const [name, score] of scores) {
    console.log(`${name}: ${score}`);
}
// "Alice: 95", "Bob: 87", "Charlie: 92", "Diana: 98"

// forEach
scores.forEach((value, key) => {
    console.log(`${key} scored ${value}`);
});

// keys() iterator
for (const name of scores.keys()) {
    console.log(name); // "Alice", "Bob", "Charlie", "Diana"
}

// values() iterator
for (const score of scores.values()) {
    console.log(score); // 95, 87, 92, 98
}

// entries() iterator (same as default)
for (const [name, score] of scores.entries()) {
    console.log(`${name}: ${score}`);
}

// Convert Map to Array of entries
const entriesArray = [...scores];
console.log(entriesArray);
// [['Alice', 95], ['Bob', 87], ['Charlie', 92], ['Diana', 98]]

// Convert Map to Object (only works with string keys)
const scoresObj = Object.fromEntries(scores);
console.log(scoresObj);
// { Alice: 95, Bob: 87, Charlie: 92, Diana: 98 }

Map vs Object: When to Use Which

Maps and plain objects both store key-value pairs, but they have important differences that affect which one you should choose. Understanding these differences helps you make the right architectural decision in your code.

Example: Key Differences Between Map and Object

// 1. KEY TYPES
// Objects: keys are always strings or Symbols
const obj = {};
obj[1] = 'number key';
obj['1'] = 'string key';
console.log(Object.keys(obj)); // ["1"] -- only one key, number was coerced

// Maps: keys retain their original type
const map = new Map();
map.set(1, 'number key');
map.set('1', 'string key');
console.log(map.size); // 2 -- two separate keys
console.log(map.get(1));   // "number key"
console.log(map.get('1')); // "string key"

// 2. SIZE
// Objects: must calculate manually
const objSize = Object.keys(obj).length; // O(n)

// Maps: .size is a property -- O(1)
const mapSize = map.size;

// 3. ITERATION ORDER
// Objects: string keys in insertion order, then numeric keys sorted
const orderedObj = {};
orderedObj['b'] = 2;
orderedObj['a'] = 1;
orderedObj[2] = 'two';
orderedObj[1] = 'one';
console.log(Object.keys(orderedObj)); // ["1", "2", "b", "a"]

// Maps: always in insertion order
const orderedMap = new Map();
orderedMap.set('b', 2);
orderedMap.set('a', 1);
orderedMap.set(2, 'two');
orderedMap.set(1, 'one');
console.log([...orderedMap.keys()]); // ['b', 'a', 2, 1]

// 4. PROTOTYPE POLLUTION
// Objects have a prototype chain -- inherited properties can cause issues
const plain = {};
console.log(plain['toString']); // [Function: toString] -- inherited!

// Maps have no inherited keys
const safeMap = new Map();
console.log(safeMap.get('toString')); // undefined -- clean

// 5. PERFORMANCE
// Maps are optimized for frequent additions and deletions
// Objects are optimized for static key structures with shape optimizations
Note: Use a Map when: keys are unknown at runtime, keys are not strings, you need to frequently add or remove entries, you need to know the size efficiently, or you need guaranteed insertion order for all key types. Use a plain Object when: keys are known and static, you need JSON serialization, you are working with simple configuration or records, or you need to use object destructuring and spread syntax.

Map with Non-String Keys

One of Map's most powerful features is its ability to use any value as a key. This enables patterns that are impossible with plain objects, such as using DOM elements, class instances, or functions as lookup keys.

Example: Maps with Object Keys

// Using DOM elements as keys (conceptual example)
// const elementData = new Map();
// const button = document.querySelector('#submit');
// elementData.set(button, { clicks: 0, lastClicked: null });

// Using class instances as keys
class User {
    constructor(name) {
        this.name = name;
    }
}

const permissions = new Map();
const alice = new User('Alice');
const bob = new User('Bob');

permissions.set(alice, ['read', 'write', 'admin']);
permissions.set(bob, ['read']);

console.log(permissions.get(alice)); // ['read', 'write', 'admin']
console.log(permissions.get(bob));   // ['read']

// Using functions as keys
const handlers = new Map();
function onClick() { console.log('clicked'); }
function onHover() { console.log('hovered'); }

handlers.set(onClick, { count: 0, lastRun: null });
handlers.set(onHover, { count: 0, lastRun: null });

// Increment click count
const clickData = handlers.get(onClick);
clickData.count++;
console.log(handlers.get(onClick).count); // 1

// Using regular expressions as keys
const validators = new Map();
validators.set(/^[\w.]+@[\w.]+\.\w+$/, 'Email format');
validators.set(/^\d{3}-\d{3}-\d{4}$/, 'US phone format');
validators.set(/^\d{5}(-\d{4})?$/, 'US zip code format');

// Note: regex keys require the same reference for lookup
// validators.get(/^[\w.]+@[\w.]+\.\w+$/) returns undefined (different object)
// You must store and use the same regex reference

Chaining Map Operations

Since Map.set() returns the Map itself, you can chain multiple set() calls. For more complex transformations, you often convert between Maps and arrays, apply array methods, and convert back.

Example: Map Transformations and Chaining

// Method chaining with set()
const headers = new Map()
    .set('Content-Type', 'application/json')
    .set('Authorization', 'Bearer token123')
    .set('Accept', 'application/json')
    .set('Cache-Control', 'no-cache');

// Filtering a Map (convert to array, filter, convert back)
const prices = new Map([
    ['laptop', 999],
    ['mouse', 29],
    ['keyboard', 79],
    ['monitor', 449],
    ['cable', 12]
]);

// Filter items over $50
const expensive = new Map(
    [...prices].filter(([, price]) => price > 50)
);
console.log(expensive);
// Map(3) {'laptop' => 999, 'keyboard' => 79, 'monitor' => 449}

// Map values (apply 10% discount)
const discounted = new Map(
    [...prices].map(([item, price]) => [item, Math.round(price * 0.9)])
);
console.log(discounted);
// Map(5) {'laptop' => 899, 'mouse' => 26, 'keyboard' => 71, 'monitor' => 404, 'cable' => 11}

// Sorting a Map by value
const sortedByPrice = new Map(
    [...prices].sort((a, b) => a[1] - b[1])
);
console.log([...sortedByPrice.keys()]);
// ['cable', 'mouse', 'keyboard', 'monitor', 'laptop']

// Reduce a Map to a single value
const totalValue = [...prices.values()].reduce((sum, price) => sum + price, 0);
console.log(`Total inventory value: $${totalValue}`); // "Total inventory value: $1568"

Real-World Examples

Sets and Maps are invaluable in everyday programming. Here are several practical patterns that demonstrate their real-world utility.

User Session Management with Map

Example: Session Management

class SessionManager {
    constructor(maxAge = 30 * 60 * 1000) { // 30 minutes default
        this.sessions = new Map();
        this.maxAge = maxAge;
    }

    createSession(userId, data = {}) {
        const sessionId = crypto.randomUUID
            ? crypto.randomUUID()
            : Math.random().toString(36).substring(2);

        this.sessions.set(sessionId, {
            userId,
            data,
            createdAt: Date.now(),
            lastAccessed: Date.now()
        });

        return sessionId;
    }

    getSession(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) return null;

        // Check if session has expired
        if (Date.now() - session.lastAccessed > this.maxAge) {
            this.sessions.delete(sessionId);
            return null;
        }

        // Update last accessed time
        session.lastAccessed = Date.now();
        return session;
    }

    destroySession(sessionId) {
        return this.sessions.delete(sessionId);
    }

    getActiveCount() {
        this.cleanup();
        return this.sessions.size;
    }

    cleanup() {
        const now = Date.now();
        for (const [id, session] of this.sessions) {
            if (now - session.lastAccessed > this.maxAge) {
                this.sessions.delete(id);
            }
        }
    }

    getUserSessions(userId) {
        const userSessions = [];
        for (const [id, session] of this.sessions) {
            if (session.userId === userId) {
                userSessions.push({ id, ...session });
            }
        }
        return userSessions;
    }
}

const sessions = new SessionManager(60000); // 1 minute timeout
const sid = sessions.createSession('user_42', { role: 'admin' });
console.log(sessions.getSession(sid)); // { userId: 'user_42', data: {...}, ... }
console.log(sessions.getActiveCount()); // 1

Caching with Map

Example: LRU Cache Implementation

class LRUCache {
    constructor(capacity) {
        this.capacity = capacity;
        this.cache = new Map();
    }

    get(key) {
        if (!this.cache.has(key)) return undefined;

        // Move to end (most recently used) by deleting and re-adding
        const value = this.cache.get(key);
        this.cache.delete(key);
        this.cache.set(key, value);
        return value;
    }

    put(key, value) {
        // If key exists, delete it first (to update position)
        if (this.cache.has(key)) {
            this.cache.delete(key);
        }

        // If at capacity, remove the oldest entry (first in Map)
        if (this.cache.size >= this.capacity) {
            const oldestKey = this.cache.keys().next().value;
            this.cache.delete(oldestKey);
        }

        this.cache.set(key, value);
    }

    has(key) {
        return this.cache.has(key);
    }

    get size() {
        return this.cache.size;
    }

    clear() {
        this.cache.clear();
    }
}

const cache = new LRUCache(3);
cache.put('a', 1);
cache.put('b', 2);
cache.put('c', 3);

console.log(cache.get('a')); // 1 (moves 'a' to most recent)

cache.put('d', 4); // Evicts 'b' (least recently used)

console.log(cache.has('b')); // false (was evicted)
console.log(cache.has('a')); // true
console.log(cache.has('c')); // true
console.log(cache.has('d')); // true

Tag Counting and Frequency Analysis

Example: Counting with Maps

// Count word frequency
function wordFrequency(text) {
    const freq = new Map();
    const words = text.toLowerCase().match(/\b\w+\b/g) || [];

    for (const word of words) {
        freq.set(word, (freq.get(word) || 0) + 1);
    }

    return freq;
}

const text = 'the quick brown fox jumps over the lazy dog the fox';
const freq = wordFrequency(text);
console.log(freq);
// Map(8) {'the' => 3, 'quick' => 1, 'brown' => 1, 'fox' => 2, ...}

// Get top N most frequent words
function topN(freqMap, n) {
    return [...freqMap.entries()]
        .sort((a, b) => b[1] - a[1])
        .slice(0, n)
        .map(([word, count]) => ({ word, count }));
}

console.log(topN(freq, 3));
// [{ word: 'the', count: 3 }, { word: 'fox', count: 2 }, { word: 'quick', count: 1 }]

// Tag counting for blog posts
function countTags(posts) {
    const tagCounts = new Map();

    for (const post of posts) {
        for (const tag of post.tags) {
            tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
        }
    }

    return tagCounts;
}

const blogPosts = [
    { title: 'JS Basics', tags: ['javascript', 'beginner'] },
    { title: 'React Guide', tags: ['javascript', 'react', 'frontend'] },
    { title: 'Node API', tags: ['javascript', 'nodejs', 'backend'] },
    { title: 'CSS Grid', tags: ['css', 'frontend'] }
];

const tagStats = countTags(blogPosts);
console.log(tagStats);
// Map(6) {'javascript' => 3, 'beginner' => 1, 'react' => 1,
//          'frontend' => 2, 'nodejs' => 1, 'backend' => 1, 'css' => 1}

// Group posts by tag
function groupByTag(posts) {
    const groups = new Map();

    for (const post of posts) {
        for (const tag of post.tags) {
            if (!groups.has(tag)) {
                groups.set(tag, []);
            }
            groups.get(tag).push(post.title);
        }
    }

    return groups;
}

const grouped = groupByTag(blogPosts);
console.log(grouped.get('javascript'));
// ['JS Basics', 'React Guide', 'Node API']
console.log(grouped.get('frontend'));
// ['React Guide', 'CSS Grid']

WeakSet and WeakMap

JavaScript also provides WeakSet and WeakMap variants that hold weak references to their keys (or values, in the case of WeakSet). This means entries are automatically garbage collected when the key object is no longer referenced elsewhere in your program. Weak collections are not iterable and do not have a size property, but they prevent memory leaks in scenarios where you attach data to objects that may be removed.

Example: WeakMap for Private Data

// WeakMap for truly private instance data
const _private = new WeakMap();

class Account {
    constructor(owner, balance) {
        _private.set(this, {
            owner,
            balance,
            transactions: []
        });
    }

    deposit(amount) {
        const data = _private.get(this);
        data.balance += amount;
        data.transactions.push({ type: 'deposit', amount, date: new Date() });
    }

    withdraw(amount) {
        const data = _private.get(this);
        if (amount > data.balance) {
            throw new Error('Insufficient funds');
        }
        data.balance -= amount;
        data.transactions.push({ type: 'withdrawal', amount, date: new Date() });
    }

    get balance() {
        return _private.get(this).balance;
    }

    get owner() {
        return _private.get(this).owner;
    }
}

const acct = new Account('Alice', 1000);
acct.deposit(500);
acct.withdraw(200);
console.log(acct.balance); // 1300
console.log(acct.owner);   // "Alice"

// Private data is truly inaccessible from outside
console.log(Object.keys(acct));                // []
console.log(Object.getOwnPropertySymbols(acct)); // []
// No way to access _private data without the WeakMap reference

// WeakSet for tracking processed objects
const processed = new WeakSet();

function processOnce(obj) {
    if (processed.has(obj)) {
        console.log('Already processed, skipping');
        return;
    }
    processed.add(obj);
    console.log('Processing:', obj);
}

const data = { id: 1 };
processOnce(data); // "Processing: { id: 1 }"
processOnce(data); // "Already processed, skipping"
Pro Tip: Use WeakMap when you need to associate data with objects that may be garbage collected (like DOM nodes or class instances). Use WeakSet when you need to track whether an object has been seen or processed. The "weak" in WeakMap and WeakSet means the collection does not prevent the garbage collector from reclaiming the key objects, which is essential for preventing memory leaks in long-running applications.

Combining Sets and Maps

Sets and Maps work beautifully together. Here is a practical example that combines both data structures to solve a common problem.

Example: Permission System with Sets and Maps

class PermissionSystem {
    constructor() {
        // Map of role name to Set of permissions
        this.roles = new Map();
        // Map of user ID to Set of role names
        this.userRoles = new Map();
    }

    defineRole(roleName, permissions) {
        this.roles.set(roleName, new Set(permissions));
    }

    assignRole(userId, roleName) {
        if (!this.roles.has(roleName)) {
            throw new Error(`Role "${roleName}" does not exist`);
        }
        if (!this.userRoles.has(userId)) {
            this.userRoles.set(userId, new Set());
        }
        this.userRoles.get(userId).add(roleName);
    }

    getUserPermissions(userId) {
        const roles = this.userRoles.get(userId);
        if (!roles) return new Set();

        const allPermissions = new Set();
        for (const role of roles) {
            const perms = this.roles.get(role);
            if (perms) {
                for (const perm of perms) {
                    allPermissions.add(perm);
                }
            }
        }
        return allPermissions;
    }

    hasPermission(userId, permission) {
        return this.getUserPermissions(userId).has(permission);
    }
}

const perms = new PermissionSystem();

// Define roles with permission Sets
perms.defineRole('viewer', ['read']);
perms.defineRole('editor', ['read', 'write', 'comment']);
perms.defineRole('admin', ['read', 'write', 'comment', 'delete', 'manage-users']);

// Assign roles to users
perms.assignRole('user_1', 'editor');
perms.assignRole('user_1', 'viewer'); // overlapping permissions are deduplicated
perms.assignRole('user_2', 'admin');

console.log([...perms.getUserPermissions('user_1')]);
// ['read', 'write', 'comment']

console.log(perms.hasPermission('user_1', 'write'));        // true
console.log(perms.hasPermission('user_1', 'delete'));       // false
console.log(perms.hasPermission('user_2', 'manage-users')); // true

Summary of Key Concepts

  • Set stores unique values with O(1) has(), add(), and delete() operations. Use it for deduplication and fast membership checks.
  • Set operations (union, intersection, difference, symmetric difference) can be implemented by combining spread and filter(), or by using new native methods in modern environments.
  • Map stores key-value pairs with any key type, maintains insertion order, and provides O(1) lookups with get() and has().
  • Map vs Object: choose Map when keys are dynamic, non-string, or when you need guaranteed order and frequent additions or deletions.
  • WeakSet and WeakMap hold weak references that allow garbage collection, preventing memory leaks when associating data with objects.
  • Real-world patterns include session management, caching (LRU), frequency counting, permission systems, and deduplication.

Practice Exercise

Build a complete TagManager class that uses both Set and Map internally. The class should support: (1) Adding tags to items using a Map where each item ID maps to a Set of tags. (2) A method getItemsByTag(tag) that returns all item IDs that have a specific tag. (3) A method getRelatedTags(tag) that returns a Set of all tags that appear on any item that also has the given tag (excluding the given tag itself). (4) A method getMostPopularTags(n) that uses a frequency Map to return the top N tags sorted by how many items use them. (5) A method mergeTags(oldTag, newTag) that replaces all occurrences of oldTag with newTag across all items. Test your implementation with at least 10 items and 15 different tags. Verify that deduplication works (adding the same tag twice to an item only stores it once), that tag counting is correct, and that getRelatedTags() produces accurate results. Finally, implement an LRU cache using Map that stores the results of getItemsByTag() and invalidates the cache whenever tags are modified.