bastianplsfix

Equality

The short answer is: write ===. The long answer is that JavaScript has four different ways of deciding whether two values are the same, and the other three exist for specific jobs. == converts its operands before comparing them, and the conversions are where the traps live. Object.is exists to repair two flaws in ===. And a fourth algorithm, one you never write by name, decides how includes, Set, and Map behave. Comparing contents rather than identity is not in the language at all; a library has to do it, and the library makes decisions worth knowing.

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

import { assertEquals, assertNotEquals } from "@std/assert";

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

=== on objects compares identity, not contents

Start with the most common surprise in JavaScript equality. Two objects, built from identical literals, with identical contents. Predict what === says about them, then save:

Deno.test("=== on objects compares identity, not contents", () => {
const o1 = { x: 1 };
const o2 = { x: 1 };
assertEquals(o1 === o2, true);
});
Check programs/equality.test.ts
running 1 test from ./programs/equality.test.ts
=== on objects compares identity, not contents ... FAILED (9ms)

ERRORS

=== on objects compares identity, not contents => ./programs/equality.test.ts:4:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- false
+ true

FAILURES

=== on objects compares identity, not contents => ./programs/equality.test.ts:4:11

FAILED | 0 passed | 1 failed (10ms)

error: Test failed

The diff reads: actual false, expected true. So === looked at two objects with identical contents and said they are not equal. The failed assertion is doing its job, because it points at exactly the wrong assumption.

=== on two objects never looks inside either one. It asks one question only: are these the same object? The reference page on values and references establishes the model behind that question.

  1. { x: 1 } appears twice, and each object literal that runs builds a new object, so two objects exist.
  2. o1 and o2 therefore lead to different objects.
  3. The honest answer to "same object?" is no, so o1 === o2 is false, and the contents never entered into the decision.

Correct the prediction to false, and add the other half of the claim: a name compared against itself leads to the same object both times, so that comparison is true.

Deno.test("=== on objects compares identity, not contents", () => {
const o1 = { x: 1 };
const o2 = { x: 1 };
assertEquals(o1 === o2, false);
assertEquals(o1 === o1, true);
});
Check programs/equality.test.ts
running 1 test from ./programs/equality.test.ts
=== on objects compares identity, not contents ... ok (0ms)

ok | 1 passed | 0 failed (2ms)

You can now state what === does with objects: it compares identity, meaning which object, and never contents, meaning what is inside.

=== on primitives requires same type and same value

For primitives there are no references to compare, so === asks a different pair of questions: same type, and same value. Try to compare a string against a number and save:

Deno.test("=== on primitives requires same type and same value", () => {
const text = "1";
const one = 1;
assertEquals(text === one, false);
});
Check programs/equality.test.ts
TS2367 [ERROR]: This comparison appears to be unintentional because the types 'string' and 'number' have no overlap.
assertEquals(text === one, false);
~~~~~~~~~~~~
at file:///programs/equality.test.ts:14:18

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

The program never ran. Notice which system spoke: this is TypeScript, during checking, before any JavaScript executed. TypeScript knows text is a string and one is a number, knows those types share no values, and concludes the comparison can only ever be false. A comparison with one possible answer is almost always a mistake, so the checker refuses it outright.

This diagnostic is worth pausing on, because it reframes the whole topic. Most of the famous equality puzzles cannot be written in checked TypeScript at all. To ask JavaScript these questions, we have to widen the types to unknown first, which tells the checker "I know nothing about this value, allow anything." That is exactly the situation where equality traps bite in real programs: at the edges, where data arrives from a network, a file, or a form, and the types are genuinely unknown.

Widen both names and save:

Deno.test("=== on primitives requires same type and same value", () => {
const text: unknown = "1";
const one: unknown = 1;
assertEquals(text === one, false);
assertEquals(2 + 2 === 4, true);
});
=== on primitives requires same type and same value ... ok (0ms)

Now JavaScript answered, and it said false. Here is the causal chain.

  1. text === one first compares the types.
  2. A string and a number are different types, so the answer is false immediately, and the values "1" and 1 are never compared at all. No conversion happens on the way; that refusal to convert is the whole personality of ===.
  3. 2 + 2 === 4 shows the ordinary positive case: 2 + 2 evaluates to the number 4, both sides are the same type and the same value, so the comparison is true.

NaN is never equal to anything, including itself

=== compared against itself has been true in every step so far: o1 === o1, 4 === 4. Predict whether that holds for every value in the language, with no exceptions. Then try the exception directly:

Deno.test("NaN is never equal to anything, including itself", () => {
assertEquals(NaN === NaN, true);
});
Check programs/equality.test.ts
TS2845 [ERROR]: This condition will always return 'false'.
assertEquals(NaN === NaN, true);
~~~~~~~~~~~
at file:///programs/equality.test.ts:19:18

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

TypeScript again, and this time it is not guessing from types: it recognizes the literal NaN on both sides and already knows the runtime answer. Never true. Route the value through a variable and the checker loses the certainty it needs to object, but JavaScript's answer stays the same:

Deno.test("NaN is never equal to anything, including itself", () => {
const n = NaN;
assertEquals(n === n, false);
assertEquals(Number.isNaN(n), true);
assertEquals(Number.isNaN(1), false);
});
NaN is never equal to anything, including itself ... ok (0ms)

n === n is false. A value can fail to equal itself, so x === x is not the safe bet it looks like.

Here is where the rule comes from. NaN means "not a number," and it is what arithmetic produces when no numeric answer exists; 0 / 0 is one way to make one. The behavior of NaN is not JavaScript's decision. IEEE 754, the floating-point standard that nearly every language uses, specifies that any comparison involving NaN is false, on the reasoning that "not a number" says nothing about which non-number it is, so no two of them can be called the same. JavaScript inherits that rule, and so does almost every other language you will use.

The practical consequence: never ask about NaN with ===. The question "is this value NaN" has a dedicated tool, Number.isNaN, which inspects the value directly instead of comparing it to anything. The last two assertions show it answering correctly for both a NaN and an ordinary number.

=== calls the two zeros the same value

One more exception lives inside ===, and it points the other way: two values it calls equal that the machine stores differently. Predict all three lines, then save:

Deno.test("=== calls the two zeros the same value", () => {
assertEquals(-0 === 0, true);
assertEquals(1 / 0, Infinity);
assertEquals(1 / -0, -Infinity);
});
=== calls the two zeros the same value ... ok (0ms)

All three pass, and together they make a strange picture. -0 === 0 is true, so as far as === is concerned there is one zero. But dividing by each produces oppositely signed infinities, so the program can plainly tell them apart. Both facts are real. IEEE 754 gives zero a sign bit, which makes -0 a genuinely distinct stored value, and === was deliberately defined to ignore that distinction because most code wants one zero. The sign survives arithmetic, and division is where it becomes visible.

If you are wondering when this could possibly matter: it matters in code where a value approaches zero from one side, such as geometry or animation easing, and the sign of the result decides a direction. If that is not your program, know that the second zero exists and move on.

Object.is repairs exactly those two cases

Both exceptions now have evidence: NaN breaks self-equality, and the zeros collapse. Object.is is the tool the language provides for both. Predict its three answers:

Deno.test("Object.is repairs exactly those two cases", () => {
const n = NaN;
assertEquals(Object.is(n, n), true);
assertEquals(Object.is(-0, 0), false);
assertEquals(Object.is(1, 1), true);
});
Object.is repairs exactly those two cases ... ok (0ms)

Take the three lines against the two previous steps.

  1. Object.is(n, n) is true where n === n was false: the first repair.
  2. Object.is(-0, 0) is false where -0 === 0 was true: the second repair.
  3. Object.is(1, 1) is true, because on every value that is not NaN and not a zero, Object.is and === agree completely.

The specification calls this algorithm SameValue.

That scope is also the advice. Reach for Object.is when NaN identity or the sign of a zero is the actual question you are asking. Anywhere else it is a slower, unfamiliar spelling of ===.

collections compare with SameValueZero

Arrays and collections do not use ===, and they do not use Object.is either. They use a third mix, and the evidence is that two array methods disagree about the same lookup:

Deno.test("collections compare with SameValueZero", () => {
const n = NaN;
assertEquals([n].indexOf(n), -1);
assertEquals([n].includes(n), true);
assertEquals([-0].includes(0), true);
const set = new Set([n, n, 0, -0]);
assertEquals(set.size, 2);
});
collections compare with SameValueZero ... ok (0ms)

Walk the disagreement.

  1. [n].indexOf(n) compares with ===, and === can never match NaN against anything, so indexOf reports -1, not found, for a value that is plainly in the array.
  2. [n].includes(n) compares with a different algorithm, SameValueZero, which treats NaN as equal to itself, so includes finds it. includes was added to the language later, partly to fix exactly this.
  3. [-0].includes(0) is true because SameValueZero also counts the two zeros as one value. SameValueZero is Object.is with that one repair taken back: NaN equals itself, and the zeros merge, restoring ==='s original behavior.
  4. new Set([n, n, 0, -0]) deduplicates its members with SameValueZero, so four values collapse into two members, one NaN and one zero, and size is 2.

The name never appears in code, which is why so few people learn it, but it decides real behavior: Set membership and Map key lookup both use it. Four equality algorithms are now on the table: ===, ==, SameValue, and SameValueZero.

== converts before comparing, and is not transitive

== is the fourth, and it disagrees with the others on far more than two edge cases, because it converts its operands toward a common type before comparing. Here is a fair test of whether that conversion behaves like equality should. If "" == 0 and 0 == "0", then surely "" == "0". Predict, then save:

Deno.test("== converts before comparing, and is not transitive", () => {
const empty: unknown = "";
const zero: unknown = 0;
const zeroText: unknown = "0";
assertEquals(empty == zero, true);
assertEquals(zero == zeroText, true);
assertEquals(empty == zeroText, true);
});
Check programs/equality.test.ts
running 7 tests from ./programs/equality.test.ts
=== on objects compares identity, not contents ... ok (0ms)
=== on primitives requires same type and same value ... ok (0ms)
NaN is never equal to anything, including itself ... ok (0ms)
=== calls the two zeros the same value ... ok (0ms)
Object.is repairs exactly those two cases ... ok (0ms)
collections compare with SameValueZero ... ok (0ms)
== converts before comparing, and is not transitive ... FAILED (9ms)

ERRORS

== converts before comparing, and is not transitive => ./programs/equality.test.ts:47:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- false
+ true

FAILURES

== converts before comparing, and is not transitive => ./programs/equality.test.ts:47:11

FAILED | 6 passed | 1 failed (11ms)

error: Test failed

false. Two values that each equal a third value do not equal each other. That property, transitivity, is something every intuition about equality quietly relies on, and == does not have it. Any mental model you build on top of == will eventually let you down, so the useful skill is not memorizing its table; it is seeing why no table can be consistent.

Trace the three comparisons one at a time.

  1. "" == 0: a string meets a number, so == converts the string to a number. "" converts to 0, and 0 equals 0, so the answer is true.
  2. 0 == "0": the same rule converts "0" to the number 0, and again 0 equals 0. Also true.
  3. "" == "0": both sides are already strings, so nothing converts, and the empty string is not the string "0". false.

Each comparison followed its own conversion path, and the paths do not have to agree with each other.

Correct the prediction to false, and add two more conversions to see how far this goes:

Deno.test("== converts before comparing, and is not transitive", () => {
const empty: unknown = "";
const zero: unknown = 0;
const zeroText: unknown = "0";
assertEquals(empty == zero, true);
assertEquals(zero == zeroText, true);
assertEquals(empty == zeroText, false);
assertEquals(([] as unknown) == zero, true);
assertEquals((true as unknown) == 1, true);
});
== converts before comparing, and is not transitive ... ok (0ms)

An empty array equals zero, because == converts objects to primitives before comparing, and an empty array converts to an empty string, which converts to 0. And true equals 1, because booleans convert to numbers. Blame the history rather than yourself. == was designed in 1995, in ten days, for a language meant to glue together pieces of a web page, where every value arriving from HTML was a string; being forgiving about "1" and 1 was the point. By the time the cost was clear, millions of pages depended on the behavior, and the web does not break working pages. So == stayed, and the stricter === was added beside it.

Notice also what it took to write this step at all: every operand is widened to unknown. In checked TypeScript, == between a known string and a known number is rejected with the same TS2367 diagnostic we met in === on primitives requires same type and same value, whichever operator you pick. Most of =='s bad reputation describes comparisons the checker never lets you write.

null == undefined, and nothing else passes

One == conversion is deliberate and useful, and it involves the two values whose whole job is to represent an absence. undefined is what a name holds before anything is assigned to it. null is a value you assign on purpose to say "deliberately nothing." Predict the second and third assertions:

Deno.test("null == undefined, and nothing else passes", () => {
let missing;
assertEquals(missing, undefined);
const absent: unknown = null;
assertEquals(absent == undefined, true);
assertEquals(absent === undefined, false);

function isNullish(value: unknown) {
return value == null;
}
assertEquals(isNullish(null), true);
assertEquals(isNullish(undefined), true);
assertEquals(isNullish(0), false);
assertEquals(isNullish(""), false);
});
null == undefined, and nothing else passes ... ok (0ms)

Both comparisons are consistent with everything this page has established.

  1. absent === undefined is false because null and undefined are different types, the same rule as any other === comparison.
  2. absent == undefined is true because the specification singles out exactly this pair: compared with ==, null and undefined equal each other and nothing else. Not 0, not "", not false.
  3. The four isNullish assertions prove that boundary from both sides: both kinds of nothing pass, and the two most nothing-like real values, 0 and "", do not.

That single carve-out supports an idiom. value == null reads as "is this either kind of nothing," and it is the one place experienced JavaScript programmers write == on purpose, because the === spelling takes two comparisons: value === null || value === undefined. Whether the idiom beats the long form is house style. Pick one with your team and be consistent.

deep equality compares contents, not identity

Every comparison so far ships with the language. Comparing two objects by contents does not; no operator and no built-in does it. assertEquals fills the gap with its own algorithm, called deep equality because it walks all the way into both structures. An algorithm someone wrote means decisions someone made, and they are observable:

Deno.test("deep equality compares contents, not identity", () => {
const n = NaN;
assertEquals(n, n);
assertEquals(-0, 0);
assertEquals({ a: 1 }, { a: 1 });
assertEquals(new Map([["a", 1]]), new Map([["a", 1]]));
assertEquals(new Set([1]), new Set([1]));
assertEquals(new Date(0), new Date(0));
});
deep equality compares contents, not identity ... ok (0ms)

Read the decisions off the passing lines.

  1. assertEquals(n, n) passes, so deep equality treats NaN as equal to itself, siding with Object.is against ===.
  2. assertEquals(-0, 0) passes, so it merges the zeros, siding with === against Object.is. That combination is SameValueZero again, the algorithm from collections compare with SameValueZero, now at the leaves of a deep comparison. When the sign of a zero is the actual claim in a test, assertEquals cannot state it; assertStrictEquals, which compares with Object.is, can.
  3. The remaining four lines show the walking: two separately built objects, two Maps, two Sets, and two Dates, each pair equal by contents when === would call every one of them different objects.

a property holding undefined is not a missing property

Deep equality has to answer one more question you might never think to ask: is a property that holds undefined the same as no property at all? Reading either one produces undefined, so predict what assertEquals decides:

Deno.test("a property holding undefined is not a missing property", () => {
assertEquals({ a: 1, b: undefined }, { a: 1 });
});
Check programs/equality.test.ts
running 10 tests from ./programs/equality.test.ts
=== on objects compares identity, not contents ... ok (1ms)
...
a property holding undefined is not a missing property ... FAILED (9ms)

ERRORS

a property holding undefined is not a missing property => ./programs/equality.test.ts:84:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

{
a: 1,
- b: undefined,
}

FAILURES

a property holding undefined is not a missing property => ./programs/equality.test.ts:84:11

FAILED | 9 passed | 1 failed (11ms)

error: Test failed

Not equal, and the diff shows the exact property that decided it.

  1. { a: 1, b: undefined } has a property named b, and that property holds undefined.
  2. { a: 1 } has no property named b at all.
  3. Reading b from either object would produce the same undefined, but deep equality does not compare what reads would produce. It compares the shape: the set of properties each object actually has. One shape has a b and the other does not, so the objects differ.

The step's title is the claim, so record the fact with the tool built for stating a difference:

Deno.test("a property holding undefined is not a missing property", () => {
assertNotEquals({ a: 1, b: undefined }, { a: 1 });
});
a property holding undefined is not a missing property ... ok (0ms)

deep equality respects what built the object

One more decision, and it is the one that catches people in real tests. A class instance and a plain object, holding identical fields. Predict the last assertion:

Deno.test("deep equality respects what built the object", () => {
class Point {
x = 1;
y = 2;
}
const point = new Point();
const literal = { x: 1, y: 2 };
assertEquals(point.x, literal.x);
assertEquals(point.y, literal.y);
assertNotEquals(point, literal);
});
deep equality respects what built the object ... ok (0ms)

Field by field, the two objects match: both xs equal, both ys equal, and the first two assertions prove it. The third passes anyway. point was built by Point's constructor and carries a hidden link to that class; literal was built by a literal and carries no such link. Deep equality checks that link along with the fields, so identical contents are not enough when one side is a class instance and the other is not. The values and references page meets the same rule from the other direction, where structuredClone strips the class link and the clone stops deep-equaling its source.

The reason this one hurts in practice: the two values print identically in most output, so the test fails while the diff appears to show two equal things. When that happens, the class link is the first suspect.

Ask the linter

Everything above argues for one habit, writing ===, and Deno can enforce the habit so it is not yours to remember. The eqeqeq lint rule is not in the recommended set, so nothing warns about == by default. Opt in through deno.json:

{
"lint": { "rules": { "include": ["eqeqeq"] } }
}

Then run deno lint:

error[eqeqeq]: expected '===' and instead saw '=='.
--> programs/equality.test.ts:66:14
|
66 | return value == null;
| ^^^^^^^^^^^^^
= hint: Use '==='

docs: https://docs.deno.com/lint/rules/eqeqeq

Found 8 problems
Checked 1 file

The linter flags every == in this entry, all seven of them, including the value == null idiom, and the eighth problem is the recommended set's own no-compare-neg-zero rule objecting to -0 === 0. That is the correct reading of this file: it is a museum of comparisons that application code should not contain, which is exactly why each one is worth having seen once. In application code, turn eqeqeq on, and if your house style keeps the value == null idiom, silence that one line deliberately with // deno-lint-ignore eqeqeq above it, so every remaining == in the codebase is a mistake by definition.

In practice