Skip to content
Chapter 1Lesson 2

What === actually compares

How JavaScript's equality operators compare values, and why strict equality is the one you reach for by default.

Here are two snippets that surprise almost every developer the first time they meet them. The first compares two objects with identical contents.

console.log({ id: 1 } === { id: 1 }); // false

The second compares a value to itself.

console.log(NaN === NaN); // false

JavaScript has three equality operators, plus two edge cases that the spec itself calls out. The senior habit is to reach for one operator by default and recognize the rest on sight. By the end of this lesson you’ll write === without thinking, know the two cases where its answer can mislead you, and know the rare moments when another operator fits better.

=== asks one question: are these the same value? The answer depends on what kind of value you hand it, and that split is the one you already know from the previous lesson on bindings. Primitives are values themselves, while objects are references to a value that lives elsewhere. === follows that split.

For primitives, === compares the value itself. 'ada' === 'ada' is true because the two strings are the same value: a primitive has no identity separate from its content. 42 === 42, true === true, and null === null are all true for the same reason.

For objects, === compares the reference, which is the arrow in the binding diagram from the previous lesson rather than the contents of the box it points at. Two object literals with identical contents are two separate allocations, which means two different boxes and two different arrows, so === returns false. The same object reached through two names is === to itself, because both names point at the same box.

console.log('ada' === 'ada'); // true
console.log({ id: 1 } === { id: 1 }); // false
const user = { id: 1 };
const alias = user;
console.log(user === alias); // true

So the rule to hold onto is that === answers “are these the same value?” for primitives and “are these the same allocation?” for objects. It never answers “do they have the same shape?” That is a separate question the language has no operator for, and we’ll come back to it at the end of the lesson.

There’s a second equality operator, ==, and the course never writes it.

== applies a table of coercion rules before comparing: strings get converted to numbers, objects get converted to primitives, null and undefined count as equal to each other but not to false, and the special cases stack up from there. The full table is documented, but nobody on a team remembers it correctly under deadline pressure. The one idiom that is occasionally defensible is x == null to match both null and undefined at once, and even that is unnecessary in this course’s stack. The next chapter introduces the ?? operator and explicit nullish checks, which read better and don’t drag the rest of the coercion table along.

Avoiding == doesn’t take discipline, because the tooling already enforces it. Biome’s noDoubleEquals rule, enabled in the canonical biome.json you’ll set up later in the course, flags every == and != at lint time, so a slip becomes a build error rather than something a reviewer has to catch.

=== is the right default everywhere, but there are exactly two spots where its answer will surprise you. Predict the output of this snippet before you read on.

Predict what this program prints, then press Check.

console.log(NaN === NaN); // ?
console.log(+0 === -0); // ?
console.log(0 === -0); // ?

Both behaviors are guaranteed by the spec across every JavaScript engine you’ll ever target. They aren’t bugs and they won’t change. They are the small cost of having === defined precisely enough to behave the same everywhere.

Object.is: the same as ===, except for those two cases

Section titled “Object.is: the same as ===, except for those two cases”

JavaScript has a third equality form, Object.is(a, b). Despite the name, it has nothing to do with objects. It exists to plug exactly one gap: it behaves like === for everything except the two edge cases above. Object.is(NaN, NaN) is true, and Object.is(+0, -0) is false. Everywhere else, the two agree.

console.log(Object.is(NaN, NaN)); // true — diverges from ===
console.log(Object.is(+0, -0)); // false — diverges from ===
console.log(Object.is('ada', 'ada')); // true — same as ===
console.log(Object.is({}, {})); // false — same as ===

There are two real-world places where Object.is is the right tool:

  • Custom memoization keys where bit-pattern identity matters, for instance a memoizer that needs to treat NaN as a valid input distinct from “no value.”
  • React’s reactivity bailout. When you call a state setter, React uses Object.is, not ===, to decide whether the value changed and the component needs to re-render. For objects, this comparison is reference identity, which is why passing a freshly spread object (setUser({ ...user })) re-renders even when the contents look identical: the spread is a new allocation, so the reference differs and Object.is returns false. We’ll come back to this rule in the React chapters; for now, the takeaway is that this is where Object.is shows up in real code.

So default to === everywhere, and reach for Object.is only when one of those two cases applies. When you do, add a comment explaining why, because the next reader will otherwise assume it’s a typo for ===.

The NaN === NaN rule means you can’t write if (x === NaN) to check whether a value is NaN. You need a dedicated function for that, and JavaScript provides two of them. Only one is safe.

The global isNaN(x) is the legacy form, and it coerces its argument to a number before testing. That coercion is almost never what the caller meant. isNaN('hello') is true, not because 'hello' is NaN, but because coercing 'hello' to a number produces NaN. isNaN(undefined) is true for the same reason. The function answers a different question than its name suggests.

Number.isNaN(x) skips the coercion entirely. It returns true only when the value you hand in is the actual NaN value, and false for everything else. Run the two side by side and you can see exactly where they disagree.

Predict what each form returns for the five inputs, then run. The actual results come from `inputs.map(isNaN)` and `inputs.map(Number.isNaN)` — your job is to fill in the prediction arrays and see whether they match. Pay attention to where the two forms disagree.

    The five-input run makes the rule concrete: the global form gives misleading answers for 'hello' and undefined, while the namespaced form reports only whether the value is actually NaN. The same logic applies to Number.isFinite(x) over isFinite(x), where the global coerces and the namespaced form doesn’t. Whenever you’re checking a property of a number, reach for the Number.* functions, because the unprefixed globals are legacy forms that coerce.

    Structural equality isn’t in the language

    Section titled “Structural equality isn’t in the language”

    There’s one comparison we haven’t covered: checking two objects for “same shape, same values.” JavaScript has no built-in operator or function for that. Comparing { id: 1, name: 'Ada' } to another object with identical fields and identical values requires either a third-party library (fast-deep-equal, lodash.isEqual) or a hand-written recursive walk. The course does not pull in a library for this in the early units.

    Instead, the course teaches the patterns that avoid the question entirely. Structural equality rarely needs to be written by hand when your data model is shaped well. These are the three patterns you’ll see in this course’s stack:

    • Compare by primary key. Two invoice rows are the same invoice when a.id === b.id, and only then. Database-shaped data carries its identity in the ID column, so equality at the application layer reduces to comparing two strings.
    • Derive a stable string key from the relevant fields. When you need to detect changes for caching or deduplication, build a key (`${invoiceId}:${status}:${updatedAt}`) and compare strings.
    • Use discriminated unions where each variant has its own shape. The TypeScript chapter coming up later in this unit goes deep on this pattern; comparisons that matter become comparisons on the discriminant field, not on the whole object.

    Once you reach the validation and database chapters (Zod in the Forms and Validation unit, Drizzle in the Postgres unit), you’ll see that the comparisons at the network and database boundaries are ID-based by construction. In well-shaped code, the need to deep-compare two objects rarely comes up.

    Here is one question to confirm you can pick the right operator for each situation without re-deriving the rule from scratch. Read each option independently.

    Which of these expressions should you write in 2026 SaaS code? Select all that apply.

    userA == userB
    userA === userB
    Number.isNaN(parsedValue)
    isNaN(parsedValue)
    Object.is(prevState, nextState)

    If you picked exactly those two, you know the full set of operators and when each applies. Every equality decision in the rest of the course, from React state updates to Zod validation comparisons to Drizzle row equality, follows the same rule: use === by default, recognize the two edge cases, and reach for the namespaced Number.* functions when you’re asking about a number’s properties.