Every JavaScript developer knows "use === not ==". But knowing the rule without understanding why leads to surprises — especially around null, undefined, and object comparisons where the behavior isn't what you'd expect even with ===.
The Core Difference
=== (strict equality): Compares type AND value. If the types differ, it returns false immediately — no conversion, no coercion.
== (loose equality): If the types differ, JavaScript tries to coerce one or both values to the same type before comparing.
1 === 1 // true — same type, same value
1 === '1' // false — different types, stops here
1 === true // false — different types
1 == 1 // true — same type, same value 1 == '1' // true — '1' is coerced to 1 1 == true // true — true is coerced to 1
How === Works
Strict equality follows one rule: same type AND same value. No exceptions, no conversions.
The only gotcha: NaN === NaN is false. NaN is the one value in JavaScript that is not equal to itself. This is defined by the IEEE 754 floating-point standard that JavaScript inherits.
1 === 1 // true
'hello' === 'hello' // true
true === true // true
null === null // true
undefined === undefined // true
null === undefined // false — different types NaN === NaN // false — NaN is never equal to itself {} === {} // false — different object references [] === [] // false — different array references
The last two are crucial: === on objects and arrays compares references, not contents. Two different objects with identical properties are not strictly equal.
const a = { x: 1 }
const b = { x: 1 }
const c = a
a === b // false — different objects in memory a === c // true — same reference
How == Coercion Works
This is where most developers' knowledge gets fuzzy. The Abstract Equality Comparison algorithm in the spec defines exactly what happens when types differ:
Rule 1: null == undefined is true (and nothing else)
null == undefined // true — special case, hardcoded in the spec null == 0 // false null == '' // false null == false // false undefined == 0 // false undefined == false // false
This is the one genuinely useful == behavior. Checking value == null catches both null and undefined in one expression. Many linting configs allow this specific pattern.
Rule 2: number vs string — string becomes a number
1 == '1' // true — '1' → 1 1 == '01' // true — '01' → 1 0 == '' // true — '' → 0 0 == '0' // true — '0' → 0 0 == ' ' // true — ' ' → 0 (whitespace-only string → 0)
Rule 3: boolean — boolean becomes a number first
true == 1 // true — true → 1, then 1 == 1 false == 0 // true — false → 0, then 0 == 0 true == '1' // true — true → 1, then '1' → 1 false == '' // true — false → 0, then '' → 0 true == 'true' // false — true → 1, then 'true' → NaN, 1 != NaN
The boolean-to-number conversion catches developers who expect if (value == true) to check for truthiness. It doesn't — it converts true to 1 and then compares numerically.
Rule 4: object vs primitive — object is converted via valueOf/toString
[] == 0 // true — [] → '' → 0 [] == '' // true — [] → '' [] == false // true — [] → '' → 0, false → 0 [1] == 1 // true — [1] → '1' → 1 [[]] == 0 // true — [[]] → [] → '' → 0
These are the results that make == look broken. They're technically correct per the spec — and exactly why you shouldn't use == with non-primitive values.
The Comparison Table Everyone Needs
// Surprising == results:
'' == '0' // false
0 == '' // true
0 == '0' // true
false == 'false' // false
false == '0' // true
false == undefined // false
false == null // false
null == undefined // true
'\t\r\n' == 0 // true
This is not a hypothetical edge case list — every one of these has caused a production bug somewhere.
Object.is() — The Third Equality
ES2015 added Object.is() which is like === but fixes the two edge cases:
// === vs Object.is()
NaN === NaN // false
Object.is(NaN, NaN) // true ← fixed
0 === -0 // true Object.is(0, -0) // false ← fixed
// Everything else: same as === Object.is(1, 1) // true Object.is(null, null) // true Object.is({}, {}) // false
React uses Object.is() internally for state comparison — useState and useMemo use it to decide if a re-render or recomputation is needed. The NaN === NaN fix matters there: if state is NaN, React correctly detects that setting state to NaN again is no change.
The One Legitimate Use of ==
// Checking for null OR undefined in one expression:
if (value == null) {
// runs when value is null or undefined — nothing else
}
// Equivalent to: if (value === null || value === undefined) { // same result, more verbose }
This is the only == pattern worth keeping. It's common enough that ESLint's eqeqeq rule has an explicit null exception option: "eqeqeq": ["error", "always", { "null": "ignore" }].
Interview Questions This Generates
"What does [] == ![] evaluate to?"
[] == ![]
// Step 1: ![] — [] is truthy, so ![] is false
// Step 2: [] == false
// Step 3: false → 0 (boolean to number)
// Step 4: [] → '' → 0 (object to primitive to number)
// Step 5: 0 == 0 → true
[] == ![] // true
Yes, an empty array equals the negation of an empty array. This is the spec working as intended — and precisely why == with objects is never correct.
"Why is NaN !== NaN?"
NaN means "Not a Number" — the result of an invalid numeric operation. Since different invalid operations produce NaN (Infinity - Infinity, 0/0, parseInt('x')), two NaN values don't necessarily come from the same operation, so they can't be considered equal. Use Number.isNaN() to check for NaN — not === NaN and not the older global isNaN() which coerces before checking.
const result = parseInt('invalid')
result === NaN // false — always false for NaN
Number.isNaN(result) // true ← correct check
isNaN('hello') // true — coerces 'hello' to NaN first, unreliable
Number.isNaN('hello') // false — 'hello' is not NaN, it's a string
The Rule
Use === everywhere. The only exception worth considering is == null to check for both null and undefined simultaneously. For everything else, === gives you predictable, type-safe comparisons with no surprises. When you need to handle NaN or distinguish -0 from 0, use Object.is().