bastianplsfix

Sets

A Set holds each value at most once and answers "is this in here" quickly. Four things carry it: add, has, delete, and the size property, with values compared exactly as a Map compares keys, from the maps page's keys are compared with SameValueZero: by value for primitives, by identity for objects.

Two things make this entry worth reading past that answer. Since ES2025 there are seven methods of set algebra, and they accept a wider argument than you would expect: not just another Set but anything Set-like, a protocol worth knowing because the mistake it permits produces a genuinely bewildering error.

Create programs/sets.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

three ways to make one, and the four methods

Deno.test("three ways to make one, and the four methods", () => {
const empty = new Set<string>();
assertStrictEquals(empty.size, 0);

const fromIterable = new Set(["red", "green"]);
const chained = new Set<string>().add("red").add("green");
assertEquals(fromIterable, chained);

const colours = new Set<string>();
colours.add("red");
colours.add("red");
assert(colours.has("red"));
assertStrictEquals(colours.size, 1);
assertStrictEquals(colours.delete("red"), true);
assertStrictEquals(colours.delete("red"), false);
assertFalse(colours.has("red"));

colours.add("blue").add("green");
assertStrictEquals(colours.size, 2);
colours.clear();
assertStrictEquals(colours.size, 0);
});
Check programs/sets.test.ts
running 1 test from ./programs/sets.test.ts
three ways to make one, and the four methods ... ok (273µs)

ok | 1 passed | 0 failed (1ms)

The constructor takes any iterable, add chains because it returns the set, and delete reports whether it removed anything, the same shape as a Map's set and delete. Adding a value that is already there does nothing at all, quietly, which is the entire point: the second add("red") left size at one.

removing duplicates keeps the first occurrence

Deno.test("removing duplicates keeps the first occurrence", () => {
const withRepeats = ["b", "a", "b", "c", "a"];

assertEquals([...new Set(withRepeats)], ["b", "a", "c"]);
assertEquals(Array.from(new Set(withRepeats)), ["b", "a", "c"]);
});
removing duplicates keeps the first occurrence ... ok (102µs)

The most common use of a Set by a wide margin, and worth having early: into a set and back out, with spread or Array.from doing the way out. Note the order. Each value keeps the position of its first occurrence, because that is when it was inserted, the same insertion-order rule the maps page pinned for entries.

seven methods of set algebra, and none of them mutate

Deno.test("seven methods of set algebra, and none of them mutate", () => {
const left = new Set(["a", "b"]);
const right = new Set(["b", "c"]);

assertEquals(left.union(right), new Set(["a", "b", "c"]));
assertEquals(left.intersection(right), new Set(["b"]));
assertEquals(left.difference(right), new Set(["a"]));
assertEquals(left.symmetricDifference(right), new Set(["a", "c"]));

assert(left.isSubsetOf(new Set(["a", "b", "c"])));
assert(new Set(["a", "b", "c"]).isSupersetOf(left));
assert(left.isDisjointFrom(new Set(["x"])));

assertEquals([...left], ["a", "b"]);
assertEquals([...right], ["b", "c"]);

assert(left.isSubsetOf(left));
assert(left.isSupersetOf(left));
assert(new Set().isSubsetOf(left));
assert(left.isDisjointFrom(new Set()));
});
seven methods of set algebra, and none of them mutate ... ok (67µs)

Four methods return a new set and three return a boolean, and the names say what they do with one exception worth defining plainly: symmetric difference is the values in one set or the other but not both. Nothing here mutates, including the receiver, as the two spreads in the middle confirm, which is the modern convention the mutating arrays page describes from the array side. The last four lines are the edges, confirmed once so you never wonder: a set contains itself, the empty set is inside everything, and the empty set shares nothing with anything.

intersection returns the smaller side's order

bigger was built in the order c, b, a, and it is the receiver. Predict the order of the intersection:

Deno.test("intersection returns the smaller side's order", () => {
const bigger = new Set(["c", "b", "a"]);
const smaller = new Set(["a", "b"]);

assertEquals([...bigger.intersection(smaller)], ["b", "a"]);
});
Check programs/sets.test.ts
running 4 tests from ./programs/sets.test.ts
...
intersection returns the smaller side's order ... FAILED (8ms)

ERRORS

intersection returns the smaller side's order => ./programs/sets.test.ts:64:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
- "a",
"b",
+ "a",
]

FAILURES

intersection returns the smaller side's order => ./programs/sets.test.ts:64:6

FAILED | 3 passed | 1 failed (10ms)

error: Test failed

The result came out in smaller's order, not the receiver's. intersection walks the smaller of the two sets and checks each value against the larger, because that is the cheaper direction, so the result arrives in the smaller side's order whichever side that happens to be, and a tie goes to the receiver. Correct the prediction and pin all three cases:

Deno.test("intersection returns the smaller side's order", () => {
const bigger = new Set(["c", "b", "a"]);
const smaller = new Set(["a", "b"]);

assertEquals([...bigger.intersection(smaller)], ["a", "b"]);
assertEquals([...smaller.intersection(bigger)], ["a", "b"]);

const tieA = new Set(["c", "b"]);
const tieB = new Set(["b", "c"]);
assertEquals([...tieA.intersection(tieB)], ["c", "b"]);
});
intersection returns the smaller side's order ... ok (29µs)

So the order of an intersection is not a property of your code, it is a property of the relative sizes of your data. Which is fine, until a test asserts an order, passes for a year, and breaks when a fifth element arrives on the other side. If the order of a result matters, sort it or rebuild it from the side you meant.

another set only has to be Set-like

Deno.test("another set only has to be Set-like", () => {
const setLike = {
size: 1,
has(value: string): boolean {
return value === "b";
},
*keys(): Generator<string> {
yield "b";
},
};

const letters = new Set(["a", "b", "c"]);

assertEquals(letters.difference(setLike), new Set(["a", "c"]));
assert(letters.isSupersetOf(setLike));
assert(new Set(["b"]).isSubsetOf(setLike));

assertEquals(letters.difference(new Map([["b", true]])), new Set(["a", "c"]));
});
another set only has to be Set-like ... ok (45µs)

All seven methods accept anything with three members: a size, a has(value), and a keys() returning an iterator. That is the whole Set-like protocol, and the object above satisfies it in nine lines. A Map satisfies it too, matching on its keys, which is convenient and also the reason the protocol asks for keys rather than values or Symbol.iterator: a Map has both of those and they mean something else, so compatibility with the one other standard collection decided the name. And the protocol was chosen over accepting any iterable deliberately, because a method needs to ask "do you contain this" without walking you, and only a has can answer that.

passing an array fails twice

An array is iterable and a Set is iterable, so passing an array where a set belongs is the obvious mistake, and everyone makes it once:

Deno.test("passing an array fails twice", () => {
const left = new Set(["a", "b"]);

assertEquals(left.union(["c"]), new Set(["a", "b", "c"]));
});
Check programs/sets.test.ts
TS2345 [ERROR]: Argument of type 'string[]' is not assignable to parameter of type 'ReadonlySetLike<unknown>'.
Type 'string[]' is missing the following properties from type 'ReadonlySetLike<unknown>': has, size
assertEquals(left.union(["c"]), new Set(["a", "b", "c"]));
~~~~~
at file:///programs/sets.test.ts:101:27

error: Type checking failed.

TypeScript names the interface and the two missing members, which is about as helpful as a diagnostic gets. Force a way past the checker and the run time is much less kind:

Deno.test("passing an array fails twice", () => {
const left = new Set(["a", "b"]);

assertThrows(
() => left.union(["c"] as unknown as Set<string>),
TypeError,
"The .size property is NaN",
);

assertThrows(
() => left.union({ size: 1 } as unknown as Set<string>),
TypeError,
'string "has" is not a function',
);

assertEquals(left.union(new Set(["c"])), new Set(["a", "b", "c"]));
});
passing an array fails twice ... ok (310µs)

The .size property is NaN is what an array's missing size produces after coercion, and nobody would work out from it that they passed the wrong kind of collection; the size-only object gets one member further and trips over the missing has. The fix is new Set(array), and the lesson is that this is a place where the checker is doing real work rather than paperwork.

a Set-like can be infinite, for some of the methods

Deno.test("a Set-like can be infinite, for some of the methods", () => {
const evenNumbers = {
size: Infinity,
has(value: number): boolean {
return value % 2 === 0;
},
keys(): Iterator<number> {
throw new TypeError("an infinite set cannot be listed");
},
};

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

assertEquals(digits.difference(evenNumbers), new Set([1, 3]));
assertEquals(digits.intersection(evenNumbers), new Set([0, 2]));

assertThrows(
() => digits.union(evenNumbers),
TypeError,
"an infinite set cannot be listed",
);
assertThrows(
() => digits.symmetricDifference(evenNumbers),
TypeError,
"an infinite set cannot be listed",
);
});
a Set-like can be infinite, for some of the methods ... ok (109µs)

The evens are a set with a membership test and no possible listing, and difference and intersection handle it, because neither needs to enumerate the other side: both walk your set and call the other's has. union cannot, since it has to produce every value from both sides, so it calls keys(), and the error you get is your own, thrown from your own method; symmetricDifference is in the same position. Which two need the other side listed falls straight out of what the operations mean. And the shape is genuinely useful rather than a curiosity, because "every even number", "every valid identifier", and "everything the cache has seen" are all easier to define by a test than by a list.

membership uses SameValueZero

Deno.test("membership uses SameValueZero", () => {
assertStrictEquals(new Set([NaN, NaN]).size, 1);
assert(new Set([NaN]).has(NaN));

assertStrictEquals(new Set([0, -0]).size, 1);

const objects = new Set<object>();
objects.add({}).add({});
assertStrictEquals(objects.size, 2);
});
membership uses SameValueZero ... ok (21µs)

The same comparison a Map uses for keys, so the same three consequences: NaN is found, the two zeros are one value, and two objects are never the same value no matter their contents, from the equality page's collections compare with SameValueZero. The object case is the one that matters in practice. A Set of objects is a set of identities, so it will not deduplicate two objects with equal contents; when that is what you want, put a string built from the identifying fields in the set and keep the objects elsewhere, the same advice the maps page gives for keys.

keys, values, and entries exist for symmetry with Map

Deno.test("keys, values, and entries exist for symmetry with Map", () => {
assertStrictEquals(Set.prototype[Symbol.iterator], Set.prototype.values);
assertStrictEquals(Set.prototype.keys, Set.prototype.values);

assertEquals(new Set(["a"]).entries().toArray(), [["a", "a"]]);

const seen: string[] = [];
new Set(["x"]).forEach((value, alsoValue) => {
seen.push(`${value}/${alsoValue}`);
});
assertEquals(seen, ["x/x"]);
});
keys, values, and entries exist for symmetry with Map ... ok (45µs)

Three methods and two of them are the same function: Symbol.iterator, values, and keys are one implementation, so iterating a set gives its elements whichever route you take. The maps page proved the matching identity with entries in the starring role; here entries produces [value, value] pairs and forEach passes the element twice, and both exist so a Set can drop into code written for a Map. Neither tells you anything. Worth knowing only so that meeting set.entries() in real code does not send you looking for a meaning it does not have.

a string goes in by code point

Deno.test("a string goes in by code point", () => {
assertEquals([...new Set("a🙂b")], ["a", "🙂", "b"]);
assertStrictEquals(new Set("aabbb").size, 2);
});
a string goes in by code point ... ok (20µs)

The constructor takes any iterable and a string is one, so new Set(text) gives the distinct characters. Characters meaning code points, so the emoji survives in one piece, from the text and characters page's iteration gives you code points, which is not what indexing the string would have given.

transforming means convert, transform, rebuild

No map and no filter, and the same three steps as a Map: take an iterator, transform it, hand the result to the constructor. One difference from the array version hides in the rebuild. Predict the size after mapping four offsets to their absolute values:

Deno.test("transforming means convert, transform, rebuild", () => {
const numbers = new Set([1, 2, 3, 4, 5]);

const doubled = new Set(numbers.values().map((n) => n * 2));
assertEquals(doubled, new Set([2, 4, 6, 8, 10]));

const offsets = new Set([-2, -1, 1, 2]);
const distances = new Set(offsets.values().map((n) => Math.abs(n)));
assertStrictEquals(distances.size, 4);
});
Check programs/sets.test.ts
running 11 tests from ./programs/sets.test.ts
...
transforming means convert, transform, rebuild ... FAILED (8ms)

ERRORS

transforming means convert, transform, rebuild => ./programs/sets.test.ts:178:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 2
+ 4

FAILURES

transforming means convert, transform, rebuild => ./programs/sets.test.ts:178:6

FAILED | 10 passed | 1 failed (10ms)

error: Test failed

Mapping a set can shrink it. Two different inputs may map to the same output, -2 and 2 both became 2, and the set keeps one, so four elements went in and two came out. That is correct, occasionally exactly what you want, and surprising the first time a map returns fewer elements than it received. Correct the prediction to 2 and pin the contents:

Deno.test("transforming means convert, transform, rebuild", () => {
const numbers = new Set([1, 2, 3, 4, 5]);

const doubled = new Set(numbers.values().map((n) => n * 2));
assertEquals(doubled, new Set([2, 4, 6, 8, 10]));

const even = new Set(numbers.values().filter((n) => n % 2 === 0));
assertEquals(even, new Set([2, 4]));

const offsets = new Set([-2, -1, 1, 2]);
const distances = new Set(offsets.values().map((n) => Math.abs(n)));
assertStrictEquals(distances.size, 2);
assertEquals(distances, new Set([1, 2]));
});
transforming means convert, transform, rebuild ... ok (39µs)

.values() gives the iterator and the iterator helpers page gives the methods, so nothing is materialised between the set and its replacement. Where helpers are not available, Array.from(set) in place of set.values().

an empty constructor is Set<unknown>, and it refuses

One more constructor default, and this one behaves. Chain onto an empty unannotated Set and try to use what comes out:

Deno.test("an empty constructor is Set<unknown>, and it refuses", () => {
const loose = new Set().add("a");
const value: string = loose.values().next().value;
assertStrictEquals(value, "a");
});
Check programs/sets.test.ts
TS2322 [ERROR]: Type 'unknown' is not assignable to type 'string'.
const value: string = loose.values().next().value;
~~~~~
at file:///programs/sets.test.ts:196:9

error: Type checking failed.

new Set() with nothing to infer from is Set<unknown>, and unknown refuses to be used until you narrow it. Compare the maps page's get is honest, and an unannotated Map is not, where the same mistake produced Map<any, any> and let a number pass as a string in silence. Two constructors in the same family with different defaults, and this is the one that behaves: the checker catches the omission at the first use rather than never. Narrow the loose value, or annotate and skip the ceremony:

Deno.test("an empty constructor is Set<unknown>, and it refuses", () => {
const loose = new Set().add("a");
const first = loose.values().next().value;
if (typeof first === "string") {
assertStrictEquals(first.toUpperCase(), "A");
}

const tight = new Set<string>().add("a");
const [only] = tight;
assertStrictEquals(only.toUpperCase(), "A");
});
an empty constructor is Set<unknown>, and it refuses ... ok (48µs)

The [only] line works because a set is iterable, taken apart by the array pattern from the destructuring page, and it is typed string because the annotation told the constructor what it holds.

a membership test is what a set is best at

Deno.test("a membership test is what a set is best at", () => {
const permitted = new Set(["read", "write"]);

function isPermitted(action: string): boolean {
return permitted.has(action);
}

assert(isPermitted("read"));
assertFalse(isPermitted("delete"));

const wanted = new Set(["a", "b", "c"]);
const present = new Set(["b", "c", "d"]);

const missing = wanted.difference(present);
const alsoMissing = new Set(wanted.values().filter((v) => !present.has(v)));

assertEquals(missing, new Set(["a"]));
assertEquals(alsoMissing, missing);
});
a membership test is what a set is best at ... ok (36µs)

A membership test reads as the question it asks, and it is constant time regardless of size, where an array's includes scans; for a handful of values the speed is irrelevant and the clarity is still the reason. The second half is the algebra paying off. wanted.difference(present) and the filter loop produce the same set, and only one of them says what it means. The filter version is what you will find in code written before ES2025, worth recognising as the same operation rather than rewriting on sight.

In practice

Related