Ch. 1 · JavaScript

JavaScript == vs ===: Type Coercion and Object Comparison

How == coerces, why {a:1} === {a:1} is false, where NaN and -0 differ between ===, Object.is and includes, and how to compare objects by value.

~7 min readbeginnerupdated Oct 19, 2026

JavaScript has four different ideas of “the same”: loose equality (==), strict equality (===), Object.is, and the SameValueZero rule used by includes, Set and Map. They agree on most values and disagree on a handful, and interviewers know exactly which handful. They also know that the most common real bug isn’t '' == 0. It’s someone expecting two objects with the same contents to be equal.

This guide starts with that identity rule, then walks through what == actually does, then covers NaN and -0, where even === gives surprising answers.

Objects compare by reference

For primitives (strings, numbers, booleans, null, undefined, symbols, bigints), === compares values. For objects, including arrays and functions, it compares references: are these two expressions pointing at the same object in memory?

console.log({ a: 1 } === { a: 1 });  // false
console.log([] === []);              // false

const o = {};
const p = o;
console.log(o === p);                // true
JavaScript

Each object literal creates a new object, so two literals are never equal no matter what’s inside them. Assigning p = o copies the reference, so both names point at one object.

This shows up in places that don’t look like equality checks at all. A Set of selected rows uses the same rule, so new Set([{ id: 1 }]).has({ id: 1 }) is false. An array’s includes and indexOf do too. A React useEffect with an object in its dependency array runs on every render if that object is recreated each time, because React compares dependencies with Object.is. In each case the fix is the same idea: compare something that has value semantics. Store ids in the Set instead of objects, depend on user.id instead of user, or memoise the object so its reference stays stable.

The wrapper objects are a small extra trap. new String('a') === 'a' is false, because the left side is an object. You’ll almost never write new String, but it’s a nice demonstration that “looks the same when printed” doesn’t mean equal.

What == actually does

Loose equality has a reputation for being random. It isn’t random; it follows a short algorithm from the spec. When the two sides have different types, it converts one or both and tries again:

  1. null and undefined are equal to each other and to nothing else.
  2. A number compared with a string: the string is converted to a number.
  3. A boolean on either side is converted to a number first (true becomes 1, false becomes 0).
  4. An object compared with a primitive is converted to a primitive, by calling valueOf and then toString (with Symbol.toPrimitive taking priority if defined).

With those four rules you can work out every “wat” example people put on slides:

console.log('' == 0);         // true:  '' becomes 0
console.log('0' == 0);        // true:  '0' becomes 0
console.log('' == '0');       // false: both strings, compared as strings
console.log(false == '0');    // true:  false becomes 0, '0' becomes 0
console.log(null == undefined, null == 0, undefined == 0);  // true false false
JavaScript

The first three lines show that == isn’t transitive. '' equals 0, and 0 equals '0', but '' doesn’t equal '0'. That one property is reason enough to avoid == in general code, because you can’t reason about chains of comparisons.

Arrays go through rule 4, which is where things get theatrical:

console.log([] == false);     // true
console.log([] == ![]);       // true
console.log([1, 2] == '1,2'); // true
JavaScript

Work through [] == ![]. The right side runs first: [] is an object, objects are truthy, so ![] is false. Now it’s [] == false. Rule 3 turns false into 0. Rule 4 turns [] into a primitive: valueOf returns the array itself (not a primitive), so toString runs and gives ''. Now it’s '' == 0, and rule 2 turns '' into 0. 0 == 0 is true. If you can narrate that in an interview, you’ve shown you understand coercion rather than memorising a table.

The one case where == earns its keep

Rule 1 is genuinely useful. value == null is true exactly when value is null or undefined, and false for 0, '' and false. Many style guides, including the ESLint eqeqeq rule’s "smart" and null: "ignore" options, allow this single use. Elsewhere, write === and convert explicitly. If a query parameter arrives as the string '2', page === 2 is false; write Number(page) === 2 so the conversion is visible to the next reader.

Relational operators coerce too

People switch to === and forget that < and > have no strict version. Two strings compare as strings, character by character, so '10' < '9' is true. A string and a number compare as numbers, so '10' < 9 is false. And null >= 0 is true even though null == 0 is false, because relational comparison converts null to 0 while == follows rule 1. Convert inputs to numbers before you compare them.

NaN, -0, and the other equality rules

=== has two famous oddities, both inherited from IEEE 754 floating-point arithmetic:

console.log(NaN === NaN);          // false
console.log(0 === -0);             // true
console.log(Object.is(NaN, NaN));  // true
console.log(Object.is(0, -0));     // false
JavaScript

NaN is not equal to anything, including itself, which is why x !== x was once the standard way to detect it. Today use Number.isNaN(x). Avoid the global isNaN, which converts its argument first, so isNaN('hello') is true.

Object.is is “same value”: it fixes both oddities, treating NaN as equal to itself and distinguishing 0 from -0. You rarely need the second part, but it matters in maths code where the sign of zero carries meaning (1 / -0 is -Infinity).

Collections use a third rule, SameValueZero: NaN equals NaN, and 0 equals -0. You can see the three rules disagree on one line:

console.log([NaN].includes(NaN), [NaN].indexOf(NaN));  // true -1
console.log(new Set([NaN, NaN, 0, -0]).size);         // 2
JavaScript

indexOf uses ===, so it never finds NaN. includes uses SameValueZero, so it does. The Set collapses the two NaNs into one entry and the two zeros into another. This is a favourite multiple-choice question, and it’s also a real bug: code that does values.indexOf(NaN) === -1 to check for missing data never finds the missing data.

Comparison NaN vs NaN 0 vs -0 Converts types?
== false true yes
===, indexOf false true no
includes, Set, Map true true no
Object.is true false no

Comparing objects by value

Since === won’t compare contents, you need to decide what “equal” means for your data. There’s no single right answer, and saying so is part of a good interview response.

For entities with an identity, compare the identity: two Order objects are the same order if a.id === b.id, even if one has a newer status. For value objects like a money amount or a coordinate, compare the fields that define the value: a.amount === b.amount && a.currency === b.currency. For a quick check in a test, assert.deepStrictEqual in Node or expect(a).toEqual(b) in Jest does a recursive comparison.

JSON.stringify(a) === JSON.stringify(b) gets suggested a lot and has real problems. Key order matters, so { a: 1, b: 2 } and { b: 2, a: 1 } stringify differently. undefined properties disappear, NaN becomes null, and Date objects become strings. It’s fine for a throwaway debug check, not for logic.

A general-purpose deep-equal function also has to decide about prototypes, cycles, Map ordering, and whether NaN equals NaN. Libraries such as Lodash’s isEqual make those decisions for you, but you should still know they’re decisions.

Interview question: why does this Set keep growing?

Question. A filter UI stores selected tags in a Set. Each click runs selected.has(tag) ? selected.delete(tag) : selected.add(tag), where tag is { id: 'urgent', label: 'Urgent' } built fresh from an API response on every render. Clicking a tag twice should deselect it, but the set just keeps growing. Why, and what would you change?

Answer. Set uses SameValueZero, which compares objects by reference. Every render builds new tag objects, so the tag passed on the second click is a different object from the one added on the first. has returns false, and add inserts a second entry that looks identical when you log it. The fix is to store a value with primitive equality: keep selected as a Set of tag.id strings and look up labels from the tag list when rendering. That also makes the selection survive refetches, because the ids stay the same even when the objects don’t. Keeping a stable object reference (memoising the tag list) would also work, but it’s fragile: the next refetch creates new objects and the bug returns. A strong answer names the comparison rule, explains why logging didn’t reveal the problem, and picks the id because ids are what the selection is really about.

Where to go next

More in JavaScript

esc