Symbols, WeakMap, WeakSet, and WeakRef

โ–ถ Try It Yourself

ES6 introduced Symbol โ€” a primitive type that is always unique โ€” along with four special collection types: Map, Set, WeakMap, and WeakSet. In ES2021, WeakRef and FinalizationRegistry completed the weak-reference picture. These features solve specific problems that plain objects and arrays cannot: truly unique keys, ordered insertion, iterable key-value pairs with any key type, and memory-efficient caches that do not prevent garbage collection. Understanding these data structures and when to reach for them distinguishes intermediate from senior JavaScript developers.

Symbol

Feature Syntax / Behaviour
Create const sym = Symbol('description') โ€” always unique
Global registry Symbol.for('key') โ€” same key returns same symbol
Retrieve key Symbol.keyFor(sym) โ€” returns registry key or undefined
As object property obj[sym] = value โ€” hidden from for...in and Object.keys
Well-known symbols Symbol.iterator, Symbol.toPrimitive, Symbol.hasInstance
typeof 'symbol'

Map vs Object, Set vs Array

Feature Map Plain Object
Key types Any value โ€” objects, functions, primitives Strings and Symbols only
Key order Insertion order guaranteed Guaranteed for strings in modern JS
Size map.size Object.keys(obj).length
Iterable Yes โ€” for...of, spread, destructuring No โ€” need Object.entries
Prototype pollution risk None Yes โ€” inherited keys from Object.prototype
Performance (many entries) Better for frequent add/delete Better for static, known structure

WeakMap / WeakSet vs Map / Set

Feature WeakMap / WeakSet Map / Set
Key / value type Objects only (no primitives) Any value
Holds reference Weak โ€” does not prevent GC Strong โ€” prevents GC
Iterable? No โ€” no size, no forEach Yes
Best for Private metadata, caches keyed by object General collections
Memory behaviour Entries removed when key object is GC’d Entries stay until manually deleted
Note: Symbol('desc') and Symbol('desc') are NOT equal โ€” every call creates a brand new unique symbol regardless of the description. The description is only for debugging (it appears in .toString()). Use Symbol.for('key') only when you intentionally want the same symbol across different modules or iframes via the global registry.
Tip: WeakMap is the perfect tool for associating private metadata with DOM elements or third-party objects without modifying those objects and without preventing garbage collection. When the element is removed from the DOM and all other references are dropped, the WeakMap entry is automatically cleaned up โ€” no manual bookkeeping required.
Warning: Because WeakMap and WeakSet are not iterable and have no size property, you cannot inspect their contents directly. This is intentional โ€” the non-deterministic nature of garbage collection means the set of live entries is unpredictable. Never use them when you need to list, count, or iterate their contents.

Basic Example

// โ”€โ”€ Symbol โ€” guaranteed uniqueness โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const id1 = Symbol('id');
const id2 = Symbol('id');
console.log(id1 === id2);        // false โ€” always unique
console.log(typeof id1);         // 'symbol'
console.log(id1.description);    // 'id'

// Symbol as non-enumerable object key
const ID    = Symbol('id');
const TYPE  = Symbol('type');

const user = {
    name:   'Alice',
    [ID]:   42,
    [TYPE]: 'admin',
};

console.log(user[ID]);           // 42
console.log(Object.keys(user));  // ['name'] โ€” symbols are hidden
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id), Symbol(type)]

// โ”€โ”€ Well-known symbols โ€” customise language behaviour โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class Temperature {
    #celsius;
    constructor(c) { this.#celsius = c; }

    [Symbol.toPrimitive](hint) {
        if (hint === 'number') return this.#celsius;
        if (hint === 'string') return `${this.#celsius}ยฐC`;
        return this.#celsius;
    }

    static [Symbol.hasInstance](instance) {
        return typeof instance?.celsius === 'number';
    }
}

const temp = new Temperature(100);
console.log(+temp);           // 100  โ€” numeric hint
console.log(`${temp}`);       // '100ยฐC' โ€” string hint
console.log(temp + 0);        // 100  โ€” default hint

// โ”€โ”€ Map โ€” any key type, iterable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const cache = new Map();

const keyObj = { id: 1 };
cache.set(keyObj,    { data: 'user data', ts: Date.now() });
cache.set('config',  { theme: 'dark' });
cache.set(42,        'forty-two');

console.log(cache.get(keyObj));    // { data: 'user data', ts: ... }
console.log(cache.has('config'));  // true
console.log(cache.size);          // 3

// Iterate
for (const [key, value] of cache) {
    console.log(key, '->', value);
}

// Convert to/from object (string keys only)
const obj = Object.fromEntries(
    [...cache].filter(([k]) => typeof k === 'string')
);

// โ”€โ”€ Set โ€” unique values โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const tags = new Set(['js', 'css', 'js', 'html', 'css']);
console.log(tags.size);    // 3 โ€” duplicates removed
console.log([...tags]);    // ['js', 'css', 'html']

// Set operations
const a = new Set([1, 2, 3, 4]);
const b = new Set([3, 4, 5, 6]);

const union        = new Set([...a, ...b]);          // {1,2,3,4,5,6}
const intersection = new Set([...a].filter(x => b.has(x)));  // {3,4}
const difference   = new Set([...a].filter(x => !b.has(x))); // {1,2}

// โ”€โ”€ WeakMap โ€” private metadata for objects โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const metadata = new WeakMap();

function registerElement(el, data) {
    metadata.set(el, { ...data, registeredAt: Date.now() });
}

function getMetadata(el) {
    return metadata.get(el);
}

const btn = document.createElement('button');
registerElement(btn, { clicks: 0, label: 'Submit' });

btn.addEventListener('click', () => {
    const meta = metadata.get(btn);
    meta.clicks++;
    console.log(`Clicked ${meta.clicks} times`);
});

// When btn is removed from DOM and all refs dropped:
// The WeakMap entry is automatically garbage collected โ€” no memory leak

// โ”€โ”€ WeakRef โ€” observe without preventing GC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class ResourcePool {
    #refs = new Map();  // id -> WeakRef

    register(id, resource) {
        this.#refs.set(id, new WeakRef(resource));
    }

    get(id) {
        const ref = this.#refs.get(id);
        return ref?.deref();   // undefined if GC'd
    }

    cleanup() {
        for (const [id, ref] of this.#refs) {
            if (!ref.deref()) this.#refs.delete(id);   // remove stale refs
        }
    }
}

How It Works

Step 1 โ€” Symbols Prevent Property Name Collisions

When multiple libraries or modules need to add properties to the same object, string keys can clash. Symbols are guaranteed unique โ€” two libraries using Symbol('id') as a key will never conflict, even if they use the same description string. This makes Symbols the safe way to add extensibility hooks to objects you do not own.

Step 2 โ€” Map Preserves Insertion Order and Accepts Any Key

Plain objects convert keys to strings โ€” an object key becomes '[object Object]', losing the reference. Map stores the actual key value by reference. Two different objects are two different Map keys, even if they look identical. This enables caches keyed by DOM elements, request objects, or user instances.

Step 3 โ€” WeakMap Holds Keys by Weak Reference

A WeakMap holds its key objects weakly โ€” if no other code holds a reference to the key object, the garbage collector can collect it, and the WeakMap entry disappears automatically. This is fundamentally different from Map, which holds keys strongly and would prevent collection. WeakMap entries are memory-safe by design.

Step 4 โ€” Well-Known Symbols Hook Into Language Behaviour

Symbol.iterator makes an object work with for...of and spread. Symbol.toPrimitive controls how an object converts to a primitive. Symbol.hasInstance customises instanceof. Symbol.asyncIterator makes objects work with for await...of. These are the extension points the language provides for customising built-in operations.

Step 5 โ€” WeakRef Enables Caches Without Memory Leaks

new WeakRef(object) holds a weak reference you can check with .deref() โ€” which returns either the object or undefined if it has been collected. Always check the return value of .deref() before using it. This enables observation patterns and optional caching without preventing the garbage collector from doing its job.

Real-World Example: Private Class State Without Private Fields

// Observable class using WeakMap for private state + Symbol for events

const _state    = new WeakMap();
const _handlers = new WeakMap();

class Observable {
    constructor(initialState) {
        _state.set(this,    { ...initialState });
        _handlers.set(this, new Map());
    }

    get(key) {
        return _state.get(this)[key];
    }

    set(key, value) {
        const state = _state.get(this);
        const prev  = state[key];
        if (prev === value) return;
        state[key] = value;
        this.#emit(key, { prev, next: value });
    }

    on(event, handler) {
        const map = _handlers.get(this);
        if (!map.has(event)) map.set(event, new Set());
        map.get(event).add(handler);
        return () => map.get(event)?.delete(handler);
    }

    #emit(event, detail) {
        _handlers.get(this)?.get(event)?.forEach(fn => fn(detail));
    }
}

const store = new Observable({ count: 0, theme: 'light' });

const off = store.on('count', ({ prev, next }) => {
    console.log(`count: ${prev} -> ${next}`);
});

store.set('count', 1);   // count: 0 -> 1
store.set('count', 2);   // count: 1 -> 2
off();                    // unsubscribe
store.set('count', 3);   // (no log โ€” handler removed)

Common Mistakes

Mistake 1 โ€” Using Symbol() when Symbol.for() is needed across modules

โŒ Wrong โ€” two modules get different symbols:

// module-a.js
const KEY = Symbol('myKey');
export { KEY };

// module-b.js โ€” a different symbol, even with same description
const KEY = Symbol('myKey');
obj[KEY];   // undefined โ€” different symbol than module-a's

โœ… Correct โ€” use Symbol.for() for shared symbols:

const KEY = Symbol.for('app.myKey');   // same symbol everywhere

Mistake 2 โ€” Using Map where a plain object is simpler

โŒ Overkill for static string-keyed data:

const config = new Map([['theme', 'dark'], ['lang', 'en']]);
config.get('theme');  // verbose for simple string key lookups

โœ… Use Map only when you need object keys, non-string keys, or frequent add/delete:

const config = { theme: 'dark', lang: 'en' };  // plain object is fine here

Mistake 3 โ€” Not calling .deref() on WeakRef

โŒ Wrong โ€” treating WeakRef as the actual object:

const ref = new WeakRef(element);
ref.style.color = 'red';  // TypeError โ€” ref is a WeakRef, not the element

โœ… Correct โ€” always deref and guard against undefined:

const el = ref.deref();
if (el) el.style.color = 'red';

▶ Try It Yourself

Quick Reference

Feature Code
Unique symbol Symbol('desc')
Shared symbol Symbol.for('key')
Map โ€” any key new Map(); map.set(key, val); map.get(key)
Set โ€” unique values new Set([1,2,3]); set.has(2)
WeakMap โ€” no leak new WeakMap(); wm.set(obj, data); wm.get(obj)
WeakSet new WeakSet(); ws.add(obj); ws.has(obj)
WeakRef new WeakRef(obj); ref.deref() ?? fallback
Custom iterator [Symbol.iterator]() { return { next() { ... } } }

🧠 Test Yourself

You need to store metadata for DOM elements and ensure the data is automatically cleaned up when the element is garbage collected. Which structure should you use?





โ–ถ Try It Yourself