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); // 3

The 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' won

The 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
QuestionCallResult
everything from botha.union(b)a new Set
only what both havea.intersection(b)a new Set
in a but not ba.difference(b)a new Set
in one but not botha.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 add returns 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 Set and has — includes scans, so the array version does up to 500 million comparisons. A Set hashes each lookup, turning the whole job into roughly 10,000 near-constant checks. Build the Set once, before the loop.
Which of these does a Set NOT give you?
map — Sets have size, has, add, delete, clear and forEach, but no map, filter or index access. Spread into an array when you need array methods.

Common mistakes

  • Writing set.length. Sets have size.
  • Expecting a Set to dedupe objects that merely look alike. Identity, not shape.
  • Rebuilding a Set inside a loop, which throws away the whole performance benefit.

Takeaways

  • [...new Set(arr)] is the dedupe idiom, and it preserves first-seen order.
  • has is a hashed lookup; includes is a scan.
  • Set equality is SameValueZero, so NaN dedupes and objects compare by reference.
  • The set operations (union, intersection, difference and friends) all return new Sets.