Sets
Mental model: A Set is a bag of unique values with instant membership checks and no indexes.
Level: intermediate · about 10 minutes
const tags = ['js', 'css', 'js', 'html', 'css'];
console.log([...new Set(tags)]); // ['js', 'css', 'html'], order preserved
console.log(new Set(tags).size); // 3The one-liner you will use most.
A **Set** holds each value at most once. Adding a duplicate is a no-op, insertion order is preserved when you iterate, and membership checks are roughly constant time instead of scanning. That last point is why a Set is not just a tidy array.
const seen = new Set();
console.log(seen.add('a').add('b').add('a')); // Set(2), add returns the Set
console.log(seen.has('a')); // true
console.log(seen.has('z')); // false
console.log(seen.delete('a')); // true, it was there
console.log(seen.delete('a')); // false, already gone
console.log(seen.size); // 1, size, not length
console.log(new Set([NaN, NaN]).size); // 1, unlike indexOf, Sets find NaN
console.log(new Set([0, -0]).size); // 1
console.log(new Set([{}, {}]).size); // 2, different objects
const shared = { id: 1 };
console.log(new Set([shared, shared]).size); // 1, same reference
Why has beats includes at scale
Array: scans until it matches
const banned = ['a', 'b', 'c'];
// O(n) per check
if (banned.includes(name)) reject();
Set: hashed lookup
const banned = new Set(['a', 'b', 'c']);
// roughly O(1) per check
if (banned.has(name)) reject();For three items it makes no difference. For a blocklist of 50,000 checked on every request, the array version is the bottleneck and the Set version is free. Build the Set once, outside the loop.
const events = [
{ id: 'a', n: 1 },
{ id: 'b', n: 2 },
{ id: 'a', n: 3 },
];
const seen = new Set();
const unique = events.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true)));
console.log(unique.map((e) => e.n)); // [1, 2], the first 'a' wonThe classic use: keep the first occurrence, drop the rest.
Iterating a Set
const langs = new Set(['js', 'go']);
for (const lang of langs) console.log(lang); // js, go, insertion order
langs.forEach((v) => console.log(v)); // same order
console.log([...langs].map((s) => s.toUpperCase())); // ['JS', 'GO']
// langs[0] is undefined: a Set has no indexes at all
Set operations
ES2025 added the seven operations people used to hand-roll with filter and spread. Each one takes any set-like argument and returns a new Set, leaving both inputs alone.
const a = new Set([1, 2, 3]);
const b = new Set([3, 4]);
console.log([...a.union(b)]); // [1, 2, 3, 4]
console.log([...a.intersection(b)]); // [3]
console.log([...a.difference(b)]); // [1, 2] (in a, not in b)
console.log([...a.symmetricDifference(b)]); // [1, 2, 4] (in exactly one)
const small = new Set([1, 2]);
const big = new Set([1, 2, 3]);
console.log(small.isSubsetOf(big)); // true
console.log(big.isSupersetOf(small)); // true
console.log(small.isDisjointFrom(new Set([9]))); // true, nothing in common
console.log([...small]); // [1, 2], inputs never change
| Question | Call | Result |
|---|---|---|
| everything from both | a.union(b) | a new Set |
| only what both have | a.intersection(b) | a new Set |
| in a but not b | a.difference(b) | a new Set |
| in one but not both | a.symmetricDifference(b) | a new Set |
| is a fully inside b? | a.isSubsetOf(b) | a boolean |
| does a contain all of b? | a.isSupersetOf(b) | a boolean |
| do they share nothing? | a.isDisjointFrom(b) | a boolean |
const s = new Set([1, 2, 2, 3]);
s.add(3);
s.delete(1);
console.log(s.size, [...s]);The duplicate 2 was ignored on construction and re-adding 3 changed nothing, so the Set held {1, 2, 3}. Deleting 1 leaves size 2, and iteration follows insertion order, so [2, 3].
Try it yourself
Set operations by hand and by method
const frontend = new Set(['js', 'css', 'html']);
const backend = new Set(['js', 'sql', 'go']);
const byHand = new Set([...frontend].filter((t) => backend.has(t)));
console.log('by hand:', [...byHand]);
console.log('built in:', [...frontend.intersection(backend)]);
console.log('either:', [...frontend.union(backend)]);
console.log('frontend only:', [...frontend.difference(backend)]);
console.log('unique to one side:', [...frontend.symmetricDifference(backend)]);
Implement intersection yourself with filter and has, then compare your result with the built-in method.
Exercises
unique
Write unique(arr) that returns a new array with duplicates removed, keeping the first occurrence of each value in its original position. It must dedupe NaN too, and it must not mutate the input.
Check yourself
- What does this log?
- 1 false — Adding an existing value does nothing, so the size stays 1, and
addreturns the Set itself, which is why chaining works. Comparison is exact, so"A"is a different value from"a". - You must check membership against 50,000 ids inside a loop over 10,000 records. What do you build?
- A
Setandhas—includesscans, so the array version does up to 500 million comparisons. ASethashes each lookup, turning the whole job into roughly 10,000 near-constant checks. Build theSetonce, before the loop. - Which of these does a
SetNOT give you? map— Sets havesize,has,add,delete,clearandforEach, but nomap,filteror index access. Spread into an array when you need array methods.
Common mistakes
- Writing
set.length. Sets havesize. - Expecting a
Setto dedupe objects that merely look alike. Identity, not shape. - Rebuilding a
Setinside a loop, which throws away the whole performance benefit.
Takeaways
[...new Set(arr)]is the dedupe idiom, and it preserves first-seen order.hasis a hashed lookup;includesis a scan.- Set equality is SameValueZero, so
NaNdedupes and objects compare by reference. - The set operations (
union,intersection,differenceand friends) all return new Sets.