skip to content

What equality rule does a JavaScript Set use to decide whether a value is already present, and what surprising results does it produce for NaN, -0, and objects?

level: middleimportance: must knowfreq 60%

answer

  1. not ===, but nearly
  2. one exception for a special number
  3. zeros of both signs agree
  4. objects: identity, never contents
  5. add quietly normalizes negative zero

basics

~20 s

A Set uses SameValueZero: like === except NaN counts as equal to itself, and +0 equals -0. So NaN deduplicates, 0 and -0 collapse into one entry stored as +0, and objects are matched by reference, never by their contents.

solid answer

~40 s

Set membership is decided by SameValueZero, which is strict equality with two adjustments: `NaN` is considered equal to `NaN`, and `+0` and `-0` are considered the same value. That is why `new Set([NaN, NaN]).size` is 1 even though `NaN === NaN` is false, and why `new Set([0, -0]).size` is 1 — `add` even normalizes `-0` to `+0`, so iterating never hands you back a negative zero. There is no coercion anywhere: `1` and `'1'` are two distinct entries. For objects the rule collapses to reference identity, so `new Set([{a:1}, {a:1}]).size` is 2, and `set.has({a:1})` is always false for an object you just created. That reference behaviour is the single biggest practical surprise.

code

javascript · 11 lines
javascript
const s = new Set([NaN, NaN, 0, -0, '0', 1, '1']);
console.log(s.size);  // 5 -> NaN, 0, '0', 1, '1'
console.log([...s]);  // [NaN, 0, '0', 1, '1']

// -0 is normalized to +0 on insertion:
console.log(Object.is([...new Set([-0])][0], -0)); // false

// Objects: reference identity only
const a = { id: 1 };
const t = new Set([a, { id: 1 }]);
console.log(t.size, t.has({ id: 1 })); // 2 false

go deeper

for a junior

Recall the two headline outcomes: a Set collapses repeated NaN values, and it does not collapse two separately-created objects that happen to look the same.

for a middle

Name the rule as SameValueZero and state its two deviations from strict equality — NaN equals itself, +0 equals -0 — then show a snippet whose size proves each one.

for a senior

Demonstrate that you design around it: choose an explicit identity function for domain objects, and know the sharp edges (add normalizes -0, boxed primitives are objects, no coercion ever) before they cause a silent dedupe failure in production data.

for a principal

Own the consequence for a system: identity semantics belong to the domain, not to the container, so decide where the canonical key is defined and how it stays stable as records evolve.

## The rule Every membership decision a `Set` makes — inside `add`, `has` and `delete` — uses the SameValueZero comparison. It is easiest to describe by difference: - It behaves like `===` for every ordinary value. - Unlike `===`, `NaN` is equal to `NaN`. - Unlike `Object.is`, `+0` and `-0` are equal. Nothing else is special. In particular there is **no coercion**: a Set never turns `1` into `'1'`, never calls `valueOf` or `toString`, and never looks inside an object. ```js const s = new Set([1, '1', true, 'true']); s.size; // 4 — four distinct values of three types ``` ## NaN deduplicates This is the headline case, because it contradicts the first fact everyone learns about `NaN`: ```js NaN === NaN; // false new Set([NaN, NaN]).size; // 1 new Set([NaN]).has(NaN); // true ``` The motivation is practical: a collection whose `has` could never find a value you had just inserted would be broken. `Array.prototype.includes` was given the same rule for the same reason, which is why `[NaN].includes(NaN)` is `true` while `[NaN].indexOf(NaN)` is `-1`. ## +0 and -0 collapse, and -0 is normalized away ```js new Set([0, -0]).size; // 1 new Set([-0]).has(0); // true ``` There is an extra detail most candidates miss. `Set.prototype.add` explicitly converts `-0` to `+0` before storing, so a Set never *contains* a negative zero at all: ```js const s = new Set([-0]); Object.is([...s][0], -0); // false Object.is([...s][0], 0); // true ``` So the sign of zero survives neither membership nor iteration. If you genuinely need to distinguish `-0` — rare, but it happens in numeric code that tracks direction of underflow — a Set is the wrong container; store a tagged string or use `Object.is` in a manual scan. ## Objects are matched by reference For object values, SameValueZero is just reference identity, and this is where the rule bites in real code: ```js const s = new Set(); s.add({ id: 1 }); s.has({ id: 1 }); // false — a different object s.size; // 1 const u = { id: 1 }; s.add(u); s.add(u); s.size; // 2 — the literal above, plus u once ``` Every object literal, every `JSON.parse` result, every row mapped out of an API response is a fresh reference. A Set of such objects deduplicates nothing. The fix is always the same shape: derive a primitive identity — an id, a normalized string — and put *that* in the Set, or key a `Map` by it if you also want to keep one object per identity. The same reasoning applies to values that merely *look* primitive. A boxed `new String('a')` is an object, so `new Set(['a', new String('a')]).size` is 2. Symbols, by contrast, are primitives but every `Symbol('x')` call creates a distinct one, so two symbols with the same description are two entries — while `Symbol.for('x')` returns the same registered symbol every time and therefore deduplicates. ## The surrounding API, and what it returns - `add(value)` returns the **Set**, so calls chain; adding a present value changes nothing, including the value's position in iteration order. - `has(value)` returns a boolean and does the SameValueZero lookup described above. - `delete(value)` returns `true` if something was removed, `false` otherwise — that boolean is a handy "was it there?" test that also removes. - `clear()` empties the Set and returns `undefined`. - `size` is an accessor **property**, not a method. - `forEach(cb)` calls back with `(value, value, set)` — the value appears twice so the signature matches `Map.prototype.forEach`, where those two slots are key and value. ## Why the rule matters in interviews It is the difference between a candidate who has memorized `[...new Set(arr)]` and one who knows what "unique" means here. Two sentences carry it: `NaN` is equal to itself so it dedupes, and objects are compared by reference so structurally identical ones do not. Everything else — the zero normalization, boxed primitives, the `delete` return value — is detail you can add when probed.

  • Why did the language choose to make NaN equal to itself for Set membership rather than reusing ===?
    Because a collection has to be able to find what it stored. With `===`, `add(NaN)` followed by `has(NaN)` would report `false`, and `delete(NaN)` could never remove it — the value would be unreachable and could be inserted endlessly. SameValueZero fixes exactly that, and the same reasoning drove `Array.prototype.includes` and `Map` keys to use it.
  • How would you build a Set that treats two records with the same id as duplicates?
    Put the identity, not the record, in the Set: keep a `Set` of ids and skip any record whose id is already present. If you also need one representative object per id, use a `Map` keyed by id instead, since re-setting a key overwrites the value while keeping the key's original position. Either way you are choosing the identity function explicitly rather than inheriting reference identity.
  • Does set.has coerce its argument the way loose equality would?
    No. There is no coercion at any point in a Set. `new Set([1]).has('1')` is `false`, and `new Set(['']).has(0)` is `false`, even though `1 == '1'` and `'' == 0` are both true. If you want type-insensitive lookup you must normalize values yourself before inserting and before querying.
  • What does set.delete return, and how is that useful?
    It returns a boolean: `true` if the value was present and has been removed, `false` if it was not there. That makes it a combined test-and-remove — `if (seen.delete(id)) { ... }` handles the "was pending, now claimed" pattern in one call, without a separate `has` check that could otherwise drift out of sync with the removal.

saying these in an interview costs you the question

  • Says NaN cannot deduplicate because NaN !== NaN
  • Claims a Set compares objects by their properties
  • Thinks Set membership coerces types like ==
  • Believes Object.is and Set membership agree on -0
  • Calls size as a method: set.size()

context