JavaScript Equality Explained: ===, ==, Relational Operators and Object.is
Learn how strict and loose equality, relational comparisons and Object.is really decide sameness in JavaScript, plus the coercion traps each one hides.
This article is published in English.
Sources: Day 4 of Learning JavaScript: The Three Ways JavaScript Asks “Are These the Same Thing?”
Picture a form where a user types their age as the text "18" and a check against the number 18 quietly passes, only for later arithmetic on that value to go wrong. Or an access check that succeeds when it should not because two different values happened to convert to the same thing. Bugs like these come from one fact: JavaScript has several ways to ask whether two values are the same, and each defines "same" differently. After reading this, you will know what ===, ==, the relational operators and Object.is() actually do, and which one to use in each situation.
Why the language has more than one notion of equality
Early JavaScript ran in browsers where almost every input arrived as text from a form. Rather than throwing an error whenever a number showed up as a string, the language was designed to be forgiving: it guessed what you meant and converted types automatically. That convenience turned into one of the most reliable sources of bugs in the language, and the stricter operators exist largely to opt out of it.
Strict equality (===) as the default
A type check followed by a value check
Strict equality performs a two-step test with no hidden conversions. First it compares the types of the two operands. If they differ, the answer is false without the values ever being examined. Only when the types match does it compare the values.
console.log(1 === 1);
// true (both are numbers, both are 1)
console.log(1 === "1");
// false (the types fail immediately: number versus string)
console.log(0 === false);
// false (number versus boolean)
console.log("" === false);
// false (string versus boolean)
console.log(null === null);
// true
console.log(null === undefined);
// false (different types)
Every false in that snippet comes from the type step: number against string, number against boolean, string against boolean, null against undefined.
String comparisons are exact down to the character. Capitalization matters, and so does invisible whitespace, which is a very common real-world failure when someone taps the spacebar after typing an email address.
console.log("hello" === "Hello");
// false (different capitalization)
console.log("john@email.com" === "john@email.com ");
// false (the second string has a hidden space at the end)
In practice this means normalizing user input, typically with trim() and often toLowerCase() for case-insensitive fields, before comparing it.
Two deliberate exceptions from IEEE 754
Strict equality is reliable almost everywhere, with two numeric exceptions. Neither is a bug; both follow the IEEE 754 floating-point standard that JavaScript numbers are built on. The first is that NaN, the "not a number" value, is not equal to anything, itself included.
const result = 0 / 0;
// result is NaN
console.log(result === NaN);
// false
console.log(NaN === NaN);
// false
Because an equality check can never detect it, use the dedicated Number.isNaN() function instead.
console.log(Number.isNaN(NaN));
// true
Prefer it over the older global isNaN(), which converts its argument to a number first and therefore reports true for strings like "abc".
The second exception concerns zero. JavaScript represents positive and negative zero separately, which matters in some numeric code, yet strict equality treats the two as equal.
console.log(+0 === -0);
// true
Objects are compared by identity
Primitives such as numbers and strings are compared by value. Objects are different: a variable holding an object really holds a reference to where that object lives in memory. Think of two houses built from the same plans and painted the same color on different streets. They look identical, but they are not the same house, because their addresses differ.
When you compare objects with ===, JavaScript asks whether both sides reference the same object and ignores what is inside. Arrays follow the same rule, since they are objects too.
const userA = { name: "Alex" };
const userB = { name: "Alex" };
console.log(userA === userB);
// false (two different objects sitting in different memory slots)
console.log([] === []);
// false (two different arrays sitting in different memory slots)
If one variable is assigned from another, both refer to the same object, and the comparison succeeds.
const original = { name: "Alex" };
const pointer = original;
console.log(original === pointer);
// true (both point to the exact same memory location)
To check whether two distinct objects contain the same data, compare their primitive properties yourself.
console.log(userA.name === userB.name);
// true (now we are comparing two strings, not two object addresses)
For nested structures that becomes a recursive comparison, which is why teams usually rely on a tested deep-equality helper from a utility library or their test framework rather than writing one ad hoc.
Strict inequality (!==)
Checking that values differ is just as common as checking that they match. Strict inequality is the exact negation of ===: it returns true when either the types or the values differ.
console.log(1 !== 2);
// true (they are different numbers)
console.log(1 !== "1");
// true (one is a number, one is a string)
console.log("apple" !== "orange");
// true
It is the natural fit whenever code should run only if a condition is not met, such as permission checks or filtering out unwanted items.
const currentRole = "guest";
if (currentRole !== "admin") {
console.log("Access denied. Admins only.");
}
// "Access denied. Admins only."
Loose equality (==) and its conversions
How coercion produces surprising answers
Before strict equality became the norm, most code used ==. When the operand types differ, loose equality converts one or both values to a common type and then compares them. The results quickly stop matching intuition.
console.log(1 == "1");
// true (the string "1" becomes the number 1)
console.log(0 == false);
// true (false becomes 0)
console.log("" == false);
// true (empty string and false both become 0)
console.log(0 == "");
// true (empty string becomes 0)
console.log([] == "");
// true (empty array becomes empty string)
console.log([1] == 1);
// true (array becomes "1", which becomes 1)
A famous example shows how many hidden steps can be involved.
console.log([] == 0);
// true
To reach true, the engine first converts the empty array to a primitive, which yields the empty string "". The types still differ, since a string is being compared with a number, so the string is converted to a number, giving 0. The final comparison is 0 == 0, which is true.
Booleans are equally tricky, because when compared with a non-boolean they are converted to numbers before anything else happens. true becomes 1.
console.log(true == "true");
// false
The string "true" does not become 1; converted to a number it becomes NaN, and 1 is not equal to NaN. This is why value == true is almost never what you want.
null and undefined follow a special rule: under loose equality they equal each other and nothing else, not even 0, false or the empty string.
console.log(null == undefined);
// true
console.log(null == 0);
// false
console.log(null == false);
// false
console.log(null == "");
// false
console.log(undefined == 0);
// false
The null check where == is defensible
Loose equality is best avoided in modern code, with one widely used exception. When handling data from an API, you often want to know whether a value is either null or undefined, and == null answers that in a single comparison.
function checkStatus(status) {
if (status == null) {
console.log("Status is missing entirely");
} else {
console.log("Status is:", status);
}
}
checkStatus(null);
// "Status is missing entirely"
checkStatus(undefined);
// "Status is missing entirely"
checkStatus(0);
// "Status is: 0"
This works precisely because of the rule above: null is loosely equal only to null and undefined, so falsy but meaningful values such as 0 or "" pass through untouched. It replaces the longer status === null || status === undefined. Be aware that many teams configure linters to forbid == entirely, in which case you write the explicit strict version. ESLint's eqeqeq rule, for example, has an option that allows exactly this null comparison while banning other uses. In many cases the nullish coalescing operator ?? or optional chaining ?. expresses the intent even more directly.
Loose inequality (!=)
!= applies the same conversions as == and then negates the result, so it inherits every surprise.
console.log(1 != "1");
// false (it converts "1" to a number, decides they are the same, so they are not different)
Here "1" is converted to the number 1, the values are judged equal, and the "not equal" check therefore returns false. Treat != with the same suspicion as == and default to !==.
Relational comparisons: <, >, <= and >=
Numbers and mixed types
With two numbers, the relational operators behave exactly as they do in arithmetic.
console.log(5 > 3);
// true
console.log(10 <= 10);
// true
When a string meets a number, the string is converted to a number first. A string that does not represent a number becomes NaN, and any relational comparison involving NaN is false.
console.log("10" > 5);
// true
console.log("abc" > 3);
// false ("abc" becomes NaN, and NaN comparisons always return false)
Where null and undefined diverge
Relational operators reveal an inconsistency with loose equality. == refuses to convert null to a number, but <, >, <= and >= do convert it, to 0.
console.log(null == 0);
// false
console.log(null > 0);
// false (0 is not greater than 0)
console.log(null >= 0);
// true (0 is greater than or equal to 0)
So null is neither equal to 0 nor greater than 0, yet it is greater than or equal to 0. The last result makes sense once you know that a >= b is evaluated as "a < b is not true", and 0 < 0 is false.
undefined behaves differently again. Converted to a number it becomes NaN, so every relational comparison involving undefined is false.
console.log(undefined > 0);
// false
console.log(undefined < 0);
// false
Strings compare by code unit, not by dictionary order
When both operands are strings, no numeric conversion takes place. The strings are compared character by character from the left, using the numeric UTF-16 code unit of each character.
console.log("apple" < "banana");
// true (a comes before b)
console.log("b" > "a");
// true (b comes after a)
Uppercase letters have lower code units than lowercase letters, which explains why a naive sort can put "Zebra" ahead of "apple".
console.log("Z" < "a");
// true (Z=90, a=97 in Unicode)
For human-facing sorting, use localeCompare() or Intl.Collator, which follow language rules instead of raw code units.
The more dangerous trap involves strings that contain digits. Compared as strings, "10" is smaller than "9".
console.log("10" < "9");
// true (the character "1" is smaller than "9")
JavaScript has no idea these strings represent quantities. It looks at the first character of each, "1" against "9", finds that "1" comes first, and stops there with true. The same effect shows up when an array of numbers is sorted with sort() and no comparator, because the default sort converts elements to strings before comparing them; pass (a, b) => a - b to sort numerically.
The fix is to convert numeric strings to numbers explicitly before comparing sizes.
console.log(Number("10") < Number("9"));
// false (correct: 10 is not less than 9)
Object.is() for exact sameness
Closing the gaps in ===
Object.is() is the most precise equality check JavaScript offers. For ordinary values it gives the same answers as strict equality.
console.log(Object.is(1, 1));
// true
console.log(Object.is(1, "1"));
// false
Where they differ is exactly the two IEEE 754 cases. Object.is() treats NaN as the same as NaN:
console.log(Object.is(NaN, NaN));
// true
and it distinguishes positive zero from negative zero:
console.log(Object.is(0, -0));
// false
The three checks side by side
1and"1":==givestrue;===andObject.is()givefalse.0andfalse:==givestrue;===andObject.is()givefalse.nullandundefined:==givestrue;===andObject.is()givefalse.NaNandNaN:==and===givefalse;Object.is()givestrue.+0and-0:==and===givetrue;Object.is()givesfalse.[]and[], or{}and{}: all three givefalse, because they are separate objects.5and5: all three givetrue.
You will rarely call Object.is() directly, but it matters in tooling you use every day. React relies on Object.is() to decide whether state or a dependency has changed and a re-render is needed, which is also why mutating an object in place does not trigger an update: the reference is still identical. State libraries apply the same idea; see how React-Redux uses equality checks to decide on re-renders for a deeper look.
Key takeaways
The opening scenario, a check that granted access it should have refused, needs nothing more exotic than == comparing a string with a number: a silent conversion makes two different values look equal. The rules that prevent it are short:
- Use
===and!==by default, everywhere. - Avoid
==and!=, except for the deliberatevalue == nullshortcut if your lint rules allow it. - Detect
NaNwithNumber.isNaN(), never with an equality operator. - Remember that object comparisons test identity, not contents.
- Convert numeric strings with
Number()before using<or>, and givesort()a comparator for numbers. - Reach for
Object.is()whenNaNor signed zero must be handled exactly.
JavaScript's forgiving conversions are not going away, but sticking to strict comparisons keeps them from leaking into production behavior.