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 }); // falseThe second compares a value to itself.
console.log(NaN === NaN); // falseJavaScript 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.
How === compares the two kinds of value
Section titled “How === compares the two kinds of value”=== 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'); // trueconsole.log({ id: 1 } === { id: 1 }); // falseconst user = { id: 1 };const alias = user;console.log(user === alias); // trueSo 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.
Why the course never writes ==
Section titled “Why the course never writes ==”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.
The two edge cases === gets wrong
Section titled “The two edge cases === gets wrong”=== 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); // ?NaN === NaN is false because IEEE 754 — the floating-point standard JavaScript’s number type is built on — specifies that NaN is not equal to anything, including itself. That spec choice is what makes NaN a useful “this calculation was invalid” marker: once a value is NaN, no accidental equality check ever “matches” it back into a valid bucket. The next lesson explains why JavaScript produces NaN values in the first place; for now the rule is observable — never compare a value to NaN with ===.
+0 === -0 (and 0 === -0) is true because the spec defines === that way. Signed zero exists — it matters for graphics, scientific computing, and a handful of hash-key designs — but in everyday SaaS code the distinction is invisible until it isn’t.
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
NaNas 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 andObject.isreturnsfalse. We’ll come back to this rule in the React chapters; for now, the takeaway is that this is whereObject.isshows 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 ===.
Number.isNaN over the global isNaN
Section titled “Number.isNaN over the global isNaN”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.
Check yourself
Section titled “Check yourself”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 == userBuserA === userBNumber.isNaN(parsedValue)isNaN(parsedValue)Object.is(prevState, nextState)The two correct picks are userA === userB (the default for every primitive and reference comparison) and Number.isNaN(parsedValue) (the senior reach for NaN checks, no coercion).
The three rejected picks:
userA == userBis forbidden on principle — the coercion table nobody remembers — and the project’s BiomenoDoubleEqualsrule turns the slip into a build error.isNaN(parsedValue)coerces its argument to a number first, so it returnstruefor'hello'andundefined. It answers a different question than its name suggests.Object.is(prevState, nextState)is the right tool only when you need its two divergences from===(treatingNaNas equal to itself, treating+0and-0as distinct) — memoization keys and framework reactivity bailouts. In ordinary application code, default to===so the next reader doesn’t assume it’s a typo.
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.
External resources
Section titled “External resources”MDN's side-by-side reference for ==, ===, Object.is, and SameValueZero. The truth tables are exhaustive and worth bookmarking the one time you need to confirm an edge case.
The lint rule that turns == from a discipline problem into a build error. The project's canonical biome.json ships with this enabled.
The full polyfill and the precise wording of how Object.is differs from === — useful when reading a memoizer or a reactivity library and wondering why.