Set Intersection and Difference
Computing the items shared between two arrays, the items in the first but not the second, or the symmetric difference is a routine task: comparing API responses, diffing user selections, finding new vs. removed records. This snippet shows the classic `Set`-backed implementations, an object-aware `byKey` variant, and the new native `Set.prototype.intersection` / `difference` methods that landed in Node 22 and 2024 browsers.
758 views
6
function intersection(a, b) {
const setB = new Set(b);
return [...new Set(a)].filter((x) => setB.has(x));
}
function difference(a, b) {
const setB = new Set(b);
return [...new Set(a)].filter((x) => !setB.has(x));
}
function symmetricDifference(a, b) {
const setA = new Set(a);
const setB = new Set(b);
const out = [];
for (const x of setA) if (!setB.has(x)) out.push(x);
for (const x of setB) if (!setA.has(x)) out.push(x);
return out;
}
const a = [1, 2, 3, 4];
const b = [3, 4, 5, 6];
console.log(intersection(a, b)); // [3, 4]
console.log(difference(a, b)); // [1, 2]
console.log(difference(b, a)); // [5, 6]
console.log(symmetricDifference(a, b)); // [1, 2, 5, 6]All three operations boil down to a hash lookup against the other side. Building one Set from b makes every membership test O(1), so the overall pass is O(|a| + |b|) instead of the O(|a| * |b|) you would get from array.includes in a filter. Wrapping a in a fresh Set first dedupes the result, which matches the mathematical definition of these operations. Note that difference is asymmetric: difference(a, b) is "in a, not in b", and symmetricDifference is the union of both halves.
function intersectionBy(a, b, keyFn) {
const keysB = new Set(b.map(keyFn));
const seen = new Set();
const out = [];
for (const item of a) {
const k = keyFn(item);
if (keysB.has(k) && !seen.has(k)) {
seen.add(k);
out.push(item);
}
}
return out;
}
function differenceBy(a, b, keyFn) {
const keysB = new Set(b.map(keyFn));
const seen = new Set();
const out = [];
for (const item of a) {
const k = keyFn(item);
if (!keysB.has(k) && !seen.has(k)) {
seen.add(k);
out.push(item);
}
}
return out;
}
const local = [{ id: 1, n: 'Ada' }, { id: 2, n: 'Bo' }, { id: 3, n: 'Cal' }];
const remote = [{ id: 2, n: 'Bo' }, { id: 3, n: 'Cal' }, { id: 4, n: 'Di' }];
console.log(intersectionBy(local, remote, (u) => u.id).map((u) => u.n));
// ['Bo', 'Cal']
console.log(differenceBy(local, remote, (u) => u.id).map((u) => u.n));
// ['Ada']
console.log(differenceBy(remote, local, (u) => u.id).map((u) => u.n));
// ['Di']Plain Set operations only work for primitives because they rely on reference identity for objects. The byKey variants take a projection function (typically returning an id or a stable string) and dedupe on that projection while keeping the original objects in the output. This is exactly the shape you want for sync diffing: differenceBy(local, remote, byId) is the "to upload" set and differenceBy(remote, local, byId) is the "to download" set. Both versions stay O(n) and avoid the quadratic blowup of nested find calls.
// Available in Node 22 and all evergreen browsers (2024+).
const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);
console.log([...setA.intersection(setB)]); // [3, 4]
console.log([...setA.difference(setB)]); // [1, 2]
console.log([...setA.symmetricDifference(setB)]); // [1, 2, 5, 6]
console.log([...setA.union(setB)]); // [1, 2, 3, 4, 5, 6]
console.log(setA.isSubsetOf(new Set([1, 2, 3, 4, 5]))); // true
console.log(setA.isDisjointFrom(new Set([7, 8]))); // true
// Works with any "set-like" right-hand side: needs .size, .has, .keys().
const setLike = { size: 2, has: (x) => x === 3 || x === 99, keys: () => [3, 99].values() };
console.log([...setA.intersection(setLike)]); // [3]The TC39 Set Methods proposal reached Stage 4 and now ships natively, so a project on Node 22+ can drop the hand-rolled helpers entirely. The right-hand argument is duck-typed as "set-like" (has size, has, and keys), which means a Map or any custom collection that exposes those three members works out of the box. Performance is at least as good as the JS implementations because the engine can use internal hash storage directly. Keep the polyfills around only if you still support Node 18 or older Safari.
function intersectAll(arrays) {
if (arrays.length === 0) return [];
return arrays.reduce((accSet, current) => {
const cur = new Set(current);
const next = new Set();
for (const x of accSet) if (cur.has(x)) next.add(x);
return next;
}, new Set(arrays[0]));
}
const a1 = [1, 2, 3, 4, 5];
const a2 = [2, 3, 4, 6];
const a3 = [3, 4, 5, 6, 7];
const a4 = [4, 5];
console.log([...intersectAll([a1, a2, a3])]); // [3, 4]
console.log([...intersectAll([a1, a2, a3, a4])]); // [4]
console.log(intersectAll([])); // []
console.log([...intersectAll([[1, 2, 3]])]); // [1, 2, 3]When you have N parallel filters (e.g. "users in cohort A, who also opted in, and also live in EU"), folding intersection over the list of arrays gives the answer in one pass per array. The accumulator stays a Set throughout to keep every membership test O(1); we only spread to an array at the very end. The shrink-as-you-go property means later arrays are checked against the already-narrow accumulator, so the cost is roughly O(sum of array sizes) in the best case. Edge cases handled explicitly: zero arrays returns [], one array returns its dedup, and any empty input array short-circuits the result to [].
