Learn JavaScript

Lesson 4 of 9 · Types, Operators and Coercion: Why '5' + 2 Is Not 7

Module 2 · Types, Operators and Coercion: Why '5' + 2 Is Not 7

When to Use == and When Not, and the Other Judgement Calls

FreeReading

In this lesson

  • Pick === by default, and write x == null, the one idiomatic ==, to catch null and undefined together.
  • Choose between Number() and parseInt, ?? and ||, toFixed and plain numbers, === and Object.is, and give the reason for each choice.
  • Read the twelve-pair chart of ==, === and Object.is, and explain why most teams let a linter enforce ===.

Amara sends Kenji a pull request, a change to the shop's code proposed for review. On the line if (discount == null), Kenji writes one comment: "never use ==". Amara's line was right, and Kenji's rule is still a good one. Both can be true, because a good default is not a law. This lesson draws the chart behind the rule, then makes five judgement calls, one program each.

Three ways to ask "equal?"

JavaScript has three equality checks. ===, strict equality, is true when both sides have the same type and the same value, with no conversion. ==, loose equality, converts first when the types differ, by the rules lessons 01 and 02 showed. Object.is(a, b), added in ES2015, works like === with two exceptions. Here are all three on the twelve pairs this module keeps coming back to.

Twelve pairs compared with ==, === and Object.is Twelve pairs, three ways to ask "equal?" # x y x == y x === y Object.is(x, y) 1 1 "1" true false false 2 0 "" true false false 3 0 "0" true false false 4 "" "0" false false false 5 null undefined true false false 6 null 0 false false false 7 undefined 0 false false false 8 NaN NaN false false true 9 0 -0 true true false 10 true 1 true false false 11 true "true" false false false 12 false "0" true false false A boxed true is true. == and === part in rows 1, 2, 3, 5, 10 and 12, where the types differ. Object.is parts from === only in rows 8 and 9: NaN, and the two zeros. Rows 2, 3 and 4: 0 == "" and 0 == "0" are true, yet "" == "0" is false.

You can run four of those rows yourself. row is a one-line arrow function that prints the three answers for one pair.

const out = [];
const row = (label, x, y) => out.push(label + ": " + (x == y) + " " + (x === y) + " " + Object.is(x, y));

row("1 and \"1\"", 1, "1");
row("null and undefined", null, undefined);
row("NaN and NaN", NaN, NaN);
row("0 and -0", 0, -0);

console.log(out.join("\n"));
1 and "1": true false false
null and undefined: true false false
NaN and NaN: false false true
0 and -0: true true false

Reading the chart

Start with the first two result columns. == and === disagree in six rows, and in every one of them the two types differ, so == converted. When the types match, the standard defines == to do exactly what === does.

Now rows 2, 3 and 4. 0 == "" is true and 0 == "0" is true, yet "" == "0" is false. Both sides of row 4 are strings, so nothing converts, and two different texts are not equal. So == is not transitive: a equal to b and b equal to c does not make a equal to c. That is the real reason to avoid it, not speed and not style.

Rows 5, 6 and 7 hold the exception. Under ==, null and undefined equal each other and nothing else, not even 0. Rows 8 and 9 are the only places where Object.is and === part. So === never surprises you, while == surprises you in exactly the rows you did not memorise.

Two checks this lesson adds

value == null
Object.is(a, b)
  • value == null is true when value is null or undefined, and false for every other value.
  • Object.is(a, b) works like a === b, except that NaN equals NaN and 0 does not equal -0.

x == null, the one == worth writing

A value can be missing in two ways. undefined means nobody set it, like a property an object never had. null means somebody cleared it on purpose. Code that reads a setting usually wants to treat both as "missing", and nothing else.

Example 1: which check catches what

Three ways to ask "is it missing?", tried on six values that all feel empty.

const out = [];
const row = (label, value) => out.push(label + ": == null " + (value == null) + ", === null " + (value === null) + ", !value " + !value);

row("null", null);
row("undefined", undefined);
row("0", 0);
row("empty string", "");
row("false", false);
row("NaN", NaN);

console.log(out.join("\n"));
null: == null true, === null true, !value true
undefined: == null true, === null false, !value true
0: == null false, === null false, !value true
empty string: == null false, === null false, !value true
false: == null false, === null false, !value true
NaN: == null false, === null false, !value true

== null catches exactly the two missing values. === null misses undefined. !value catches all six, so a discount of 0 would count as missing. Amara's discount == null was the precise check. The long form, discount === null || discount === undefined, means the same and is what a team writes when its rules forbid every ==.

Run in Compiler

Most teams let a linter decide, and that is fine

A linter is a program that reads your code and flags risky patterns; Module 1 named ESLint, a linter for JavaScript. Its rule eqeqeq flags every == and !=. The option { "null": "ignore" } lets == null through, and the option "smart" allows it too. Airbnb's style guide ships exactly this setting:

module.exports = [
  {
    rules: {
      eqeqeq: ["error", "always", { null: "ignore" }],
    },
  },
];

That is an ESLint configuration file, eslint.config.js. It prints nothing, so it has no output. jQuery's style guide says the same in words: === always, and the only exception is checking for null and undefined by way of null.

Letting a rule decide is fine, and it is better than a review comment. Nobody has to remember the chart, and reviews can argue about design instead of operators. Configured like Airbnb's, the linter would have passed Amara's line and saved Kenji his comment.

Number for the whole string, parseInt for a prefix

Lesson 03 showed the two readers: Number() wants the whole text, parseInt reads the front. The choice depends on what the text is supposed to be. The flowchart puts every converter of this module on one page.

Which converter: a decision flowchart What do you need from this value? A number, from text. Is the whole text the number? yes Number(text) then reject NaN and the empty text no, only the front The number is at the front (12px, 3.5kg). Whole numbers only? yes parseInt(text, 10) no parseFloat(text) Text to show a person? toFixed(digits) for fixed decimals Intl.NumberFormat for groups and currency true or false? Boolean(value) Any value as text? String(value) Formatting gives back text, so it is always the last step: do the arithmetic first. Number("") is 0, so check for the empty text before you trust Number().
Example 2: a quantity, a width and a version

Five texts, each read both ways. A quantity from a shop form must be a number from end to end; a width or a version number only starts with one.

const out = [];
const both = (label, text) => out.push(label + " " + text + ": Number " + Number(text) + ", parseInt " + parseInt(text, 10));

both("quantity", "12");
both("quantity", "2O");
both("quantity", "");
both("width", "250px");
both("version", "22.16.0");

console.log(out.join("\n"));
quantity 12: Number 12, parseInt 12
quantity 2O: Number NaN, parseInt 2
quantity : Number 0, parseInt NaN
width 250px: Number NaN, parseInt 250
version 22.16.0: Number NaN, parseInt 22

In "2O" a customer typed the letter O for a zero. parseInt quietly orders 2 boxes; Number says NaN, and the form can ask again. The empty field shows Number's one trap, 0, which the flowchart's first box warns about. For the width and the version, the number at the front is the whole point, so parseInt is right.

Run in Compiler

?? when 0 and empty text are real answers

Lesson 02 showed that || falls back on any falsy value, while ?? falls back only on null and undefined. The judgement call is about the data. Is 0 a real answer here, and is ""?

Example 3: settings that were saved on purpose

Bob muted his music player, so its saved volume is 0. He also chose no separator between song titles, so that is "". He never picked a theme.

const out = [];
const saved = { volume: 0, separator: "", theme: undefined };

out.push("volume with ||: " + (saved.volume || 50));
out.push("volume with ??: " + (saved.volume ?? 50));
out.push("separator with ||: [" + (saved.separator || ", ") + "]");
out.push("separator with ??: [" + (saved.separator ?? ", ") + "]");
out.push("theme with ??: " + (saved.theme ?? "light"));

console.log(out.join("\n"));
volume with ||: 50
volume with ??: 0
separator with ||: [, ]
separator with ??: []
theme with ??: light

With ||, Bob's muted player starts at volume 50, and his choice of no separator is ignored. ?? keeps both and still fills in the theme he never set. || is the right call when empty really means "not given", such as a blank display name that should show "Guest".

Run in Compiler

toFixed for display, never for arithmetic

Lesson 03 said: arithmetic first, text last. Here is the bill where the order alone is not enough. 15 percent of 48.50 is 7.275, and the total is 55.775. Both end in 5, and toFixed rounds the stored binary value, which sits a hair above or below.

Example 4: a receipt that is off by one cent

The same tip computed twice: in decimals, then in whole cents. Math.round rounds to the nearest whole number.

const out = [];
const bill = 48.5;
const percent = 15;

const tip = bill * percent / 100;
out.push("in decimals: tip " + tip.toFixed(2) + ", total " + (bill + tip).toFixed(2));

const billCents = Math.round(bill * 100);
const tipCents = Math.round(billCents * percent / 100);
const totalCents = billCents + tipCents;
out.push("in cents:    tip " + (tipCents / 100).toFixed(2) + ", total " + (totalCents / 100).toFixed(2));

console.log(out.join("\n"));
in decimals: tip 7.28, total 55.77
in cents:    tip 7.28, total 55.78

The first receipt says 48.50 plus 7.28 is 55.77. The stored 7.275 is 7.2750000000000003..., so it rounds up, while 55.775 is 55.774999999999998..., so it rounds down. In cents, 4850 times 15 over 100 is exactly 727.5, which rounds to 728, and whole numbers add exactly. So round once into whole cents, add whole numbers, and call toFixed only to print.

Run in Compiler

Object.is for NaN and -0

=== gets two cases wrong on purpose, as the standard defines it. NaN === NaN is false, Zara's test from lesson 01. 0 === -0 is true, although -0 is a real value that can show up on a screen.

Example 5: a missing reading and a negative zero

A weather station sends "n/a" for one reading, and a temperature of -0.4 is rounded for display.

const out = [];
const reading = Number("n/a");

out.push("reading === NaN: " + (reading === NaN));
out.push("Number.isNaN(reading): " + Number.isNaN(reading));
out.push("Object.is(reading, NaN): " + Object.is(reading, NaN));

const rounded = Math.round(-0.4);
out.push("rounded: " + rounded);
out.push("rounded === 0: " + (rounded === 0));
out.push("Object.is(rounded, -0): " + Object.is(rounded, -0));
out.push("(-0.4).toFixed(0): " + (-0.4).toFixed(0));

console.log(out.join("\n"));
reading === NaN: false
Number.isNaN(reading): true
Object.is(reading, NaN): true
rounded: 0
rounded === 0: true
Object.is(rounded, -0): true
(-0.4).toFixed(0): -0

=== NaN can never find a NaN; Number.isNaN is the everyday check, and Object.is agrees. Math.round(-0.4) is -0: it prints as 0 and passes === 0, but toFixed shows "-0", the weather app's "-0 degrees". Object.is is the one check that tells the two zeros apart. You will need it rarely, and that is fine.

Run in Compiler

Where this is used

  • ESLint. The eqeqeq rule's own page explains it with [] == false, [] == ![] and 3 == "03", all true, and documents the "smart" and null: "ignore" options.
  • The Airbnb JavaScript Style Guide. Its ESLint configuration sets eqeqeq to "always" with null: "ignore", so x == null passes and every other == fails.
  • TypeScript. The compiler does not ban ==. When a number meets a string under == or ===, it reports error TS2367: "This comparison appears to be unintentional because the types 'number' and 'string' have no overlap."
  • React. When a component's state is set, React compares the new value with the old one using Object.is, and skips the re-render when they are the same.

Common mistakes

1. Kenji's fix: === null on a value that can be undefined.

const order = { price: 200 };
const discount = order.discount;
const final = discount === null ? order.price : order.price * (1 - discount);
console.log("final: " + final);
final: NaN

No error. The order never had a discount, so it is undefined, not null, and 200 * (1 - undefined) is NaN. Write discount == null. You will make this change because "never use ==" sounds like a rule with no exceptions.

2. Looking for NaN with ===.

const score = Number("ten");
console.log(score === NaN ? "bad input" : "score " + score);
score NaN

The check can never be true, because NaN equals nothing under ===, itself included. Write Number.isNaN(score). You will write === NaN because it reads like plain English.

3. Using ! to mean "missing".

const quantity = 0;
console.log(!quantity ? "missing" : "quantity " + quantity);
missing

A quantity of 0 was given, and the program calls it missing, because 0 is falsy. Write quantity == null when you mean missing. You will reach for ! because it is short and works for every value you happened to test.

Brain teaser

console.log("abc" < "abd");
console.log("Z" < "a");

Both lines print true. What does that tell you about how < compares two strings? Predict "apple" < "Banana" before you run it.

Compare the two strings one position at a time, as a dictionary does. Then ask what number the computer keeps for each letter, and whether capitals come first.

Exercise 1Easy

Maria wants every row of the chart on her own screen, not only the four the lesson ran. This program prints row 1. Add rows 2 to 12 in the chart's order, run it, and check every line against the chart.

const out = [];
const row = (label, x, y) => out.push(label + ": " + (x == y) + " " + (x === y) + " " + Object.is(x, y));

row("1 and \"1\"", 1, "1");
// add rows 2 to 12 of the chart here, in the chart's order

console.log(out.join("\n"));

Not graded on its own. three-equalities, the Hard exercise below, grades the same twelve rows from typed input.

Run in Compiler
Exercise 2Medium

David's form sends one field per line. He must tell a missing field from an empty one and from a real 0, which || would lump together.

Input. A line with n, then n lines. Decode each line: exactly null is null, exactly undefined is undefined, an empty line is "", otherwise Number(line) when that is not NaN, and otherwise the text itself.

Output. One line per value: missing when the value == null, empty when it === "", zero when it === 0, and otherwise value followed by a space and String(value).

Constraints. 1 <= n <= 20000. Each line has 0 to 20 characters and no spaces at either end, and the last one is never empty.

Sample. Input 6, then null, an empty line, 0, undefined, 42 and abc, gives missing, empty, zero, missing, value 42 and value abc on six lines.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));

Graded as missing-empty-zero. This is the starter's lines variant, so next() returns a whole line, an empty one included. Use one == null check for both missing values.

Run in Compiler
Exercise 3Hard

Zara does not trust a chart she has not run. She writes each pair as typed tokens and wants the three answers for every pair.

Input. A line with n, then n lines x y. Each token is typed: num: followed by text is Number(text), so num:NaN, num:-0 and num:Infinity are allowed. str: followed by text is that string, and str: alone is "". The other tokens are bool:true, bool:false, null and undefined.

Output. One line per pair with three words: x == y, x === y and Object.is(x, y), such as true false false.

Constraints. 1 <= n <= 20000. Each token has at most 25 characters and no spaces, and the text after num: is never empty.

Sample. Input 4, then num:1 str:1, null undefined, num:NaN num:NaN and num:0 str: on four lines, gives true false false, true false false, false false true and true false false.

const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));

Graded as three-equalities. t.slice(4) gives a token without its first four characters, so "num:-0".slice(4) is "-0"; Module 3 teaches it. The hidden tests use all twelve pairs of the chart, and more.

Run in Compiler

Common doubts

  • If == null is fine, why not use == wherever it gives the right answer?

    Because you have to remember the chart every time, and so does the next reader. Row 4 shows == is not even transitive. == null is the one case short enough to remember: two values, nothing else.

  • Is === faster than ==?

    Kenji asks this, and it is the wrong question. When both sides have the same type, the standard defines == as exactly ===. This lesson measured nothing, so choose by meaning, not by speed.

  • Should I write Object.is everywhere instead of ===?

    No. It differs only for NaN and the two zeros, and readers expect ===. Use Number.isNaN for NaN, and keep Object.is for the rare code that must tell 0 from -0.

  • Does TypeScript stop me from writing ==?

    No. It reports TS2367 when two types cannot overlap, whichever operator you used. Which operator to allow is a linter's job, through eqeqeq.

Key takeaways

  • === is the default: same type, same value, no conversion, no table to remember.
  • x == null is the one idiomatic ==: it catches null and undefined and nothing else, and linters can allow it.
  • Number() for a value that must be a number from end to end, parseInt(text, 10) or parseFloat for a number at the front.
  • ?? when 0 and "" are real answers, || when every falsy value means "not given".
  • Keep money in whole cents, and call toFixed only to print; Object.is is for NaN and -0.
  • Go deeper: Under the Hood, the abstract equality algorithm and floating point (Pro).

Next, lesson 05 opens the engine and walks the algorithm behind every == row of the chart, one step at a time.

End of lesson 4

Mark it done, and your progress moves with you.

Next: Under the Hood: The Abstract Equality Algorithm and Floating Point