Writing · Article

JavaScript Set Methods: union, intersection, difference and Friends, Explained

JavaScript finally has real set operations: union, intersection, difference, symmetricDifference and the subset checks. How each works, the set-like rules, and where they beat array tricks.

For most of JavaScript's life, Set was a strangely half-finished tool. It gave us uniqueness and fast has() lookups, but the moment you wanted an actual set operation — the union of two sets, the items they share, the items in one but not the other — you were back to spreading into arrays and chaining filter. Every codebase grew the same three helper functions, and every one of them quietly ran in quadratic time when someone passed arrays instead of sets.

That era is over. The set methods proposal reached ES2025, and the seven new methods — union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf and isDisjointFrom — are Baseline available: shipped in Chrome 122, Firefox 127 and Safari 17, and available in Node.js 22. This post walks through what each one does, the slightly surprising rules about what you can pass to them, and the places they genuinely simplify real code.

The four operations that return a new Set

The first four methods are the classic Venn-diagram operations. Each returns a new Set and leaves both inputs untouched:

const frontend = new Set(["alice", "bala", "chen", "divya"]);
const oncall = new Set(["chen", "divya", "emil"]);

frontend.union(oncall);
// Set { "alice", "bala", "chen", "divya", "emil" }

frontend.intersection(oncall);
// Set { "chen", "divya" }

frontend.difference(oncall);
// Set { "alice", "bala" }  (in frontend, not oncall)

frontend.symmetricDifference(oncall);
// Set { "alice", "bala", "emil" }  (in exactly one of the two)

Two details worth internalizing. First, difference is directional: a.difference(b) keeps what is unique to a, so swapping the receiver changes the answer. Second, symmetricDifference is the one people forget exists — it answers "what changed between these two snapshots" in a single call, which previously took two filters and a concat.

Order is preserved in a predictable way: the result iterates in the insertion order of the receiver set first, then (for union and symmetricDifference) the extra items from the argument in its order. Equality is the usual SameValueZero rule sets always used — objects compare by reference, NaN equals NaN.

The three boolean checks

The remaining three methods answer questions you previously wrote as every loops:

const required = new Set(["read", "write"]);
const granted = new Set(["read", "write", "admin"]);

required.isSubsetOf(granted);   // true  - every required perm is granted
granted.isSupersetOf(required); // true  - same check, other direction

const weekend = new Set(["sat", "sun"]);
const workdays = new Set(["mon", "tue", "wed"]);
weekend.isDisjointFrom(workdays); // true - no overlap at all

isSubsetOf reads exactly like the permission checks, feature-flag gates and validation rules it replaces. isDisjointFrom is the sleeper hit: "do these two groups share nothing" is a common invariant — conflicting CSS class groups, mutually exclusive config options, reserved versus user-chosen names — and expressing it directly makes the intent auditable at a glance. Note the edge cases follow real set theory: an empty set is a subset of everything and disjoint from everything, including itself.

The set-like rule: what you can actually pass in

Here is the part that surprises people in code review. The argument to these methods does not need to be a Set — but it cannot be a plain array either. The spec requires a set-like: an object with a numeric size property, a has() method and a keys() method. Passing an array throws a TypeError, because arrays have length rather than size and no has.

const ids = new Set([1, 2, 3]);

ids.union([3, 4]);            // TypeError: not set-like
ids.union(new Set([3, 4]));   // Set { 1, 2, 3, 4 }

// Maps are set-like over their keys - this just works:
const prices = new Map([["apple", 120], ["mango", 90]]);
new Set(["apple", "banana"]).intersection(prices);
// Set { "apple" }

Why so strict? Performance. Because the method can trust has() to be a fast membership check, intersection can iterate the smaller of the two collections and probe the larger, giving sub-linear behavior that the old spread-and-filter idiom could never achieve. The array restriction is the API nudging you toward the right data structure: if the data is conceptually a set, keep it in a Set, and the conversion cost at the boundary is paid once instead of on every operation.

The set-like rule also means you can hand-roll lazy or virtual collections — an object that answers has() from a database index, say — and pass it straight into difference without materializing it. That is a genuinely new capability, not just sugar.

Real-world before and after

A pattern straight from a React codebase: deciding which tag filters to show as "active but unavailable" after the result list narrows. The old version is the kind of code that works and still reads badly:

// Before: array juggling, O(n * m)
const unavailable = selectedTags.filter(
  (t) => !visibleTags.some((v) => v === t)
);

// After: one directional difference, intent on the surface
const unavailable = selectedTags.difference(visibleTags);

And the diff-two-snapshots pattern, which shows up in cache invalidation, subscription management and sync engines alike:

const prev = new Set(prevDoc.linkedIds);
const next = new Set(nextDoc.linkedIds);

const added = next.difference(prev);
const removed = prev.difference(next);
const untouched = next.intersection(prev);

if (!added.isDisjointFrom(archivedIds)) {
  warn("linking to archived documents");
}

Every line of that maps one-to-one onto how you would describe the logic out loud, which is the whole point. The array equivalents buried the intent under mechanics.

Performance notes and one honest caveat

Engines implement these natively, and the practical wins are real: membership probes instead of nested scans, and intersection iterating the smaller side. For the common case — two sets of a few hundred to a few hundred thousand items — they comfortably beat the array idioms, and they allocate less garbage than spread-based versions.

  • Converting an array to a Set costs one pass. If you do multiple operations against the same data, convert once and keep the Set around.
  • These methods return new sets rather than mutating - chaining a.union(b).difference(c) allocates an intermediate. Fine almost always; worth knowing in hot paths.
  • There is no addAll or in-place variant. If you genuinely need mutation, a plain for...of loop with add() is still the tool.
  • For very old targets (Node 20, Safari 16 and below) you still need a polyfill - core-js covers the whole proposal.

The honest caveat: these are value-identity sets. Two objects with identical contents are still different members, so difference on sets of objects compares references, not shapes. For keyed diffing of objects, a Map keyed by id — combined with set operations on the id sets, as in the snapshot example above — remains the right pattern.

Takeaways

  • Seven methods, two families: four constructors of new sets (union, intersection, difference, symmetricDifference) and three boolean checks (isSubsetOf, isSupersetOf, isDisjointFrom).
  • Arguments must be set-like (size, has, keys) - arrays throw, Maps work, custom lazy collections are possible.
  • difference is directional; symmetricDifference is the built-in "what changed" operation.
  • Baseline since mid-2024 (Chrome 122, Firefox 127, Safari 17, Node 22) - safe to use in new code today.
  • Reach for them anywhere you wrote spread-plus-filter set logic; keep Map for keyed object diffing.