Learn JavaScript

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

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

Full Programs: Converting on Purpose

FreeReading

In this lesson

  • Write six complete programs that turn text into numbers, numbers into text and any value into true or false, each conversion chosen on purpose.
  • Explain why isNaN("abc") is true while Number.isNaN("abc") is false, and why parseInt("12px", 10) is 12 while Number("12px") is NaN.
  • Format a number for a person with toFixed and Intl.NumberFormat, and know that both give back a string.

Think of the calculator app on a phone. Under its buttons sits a small core that turns the typed text into numbers, does the sum and turns the answer back into text. Every line of that core is a conversion somebody chose. Lessons 01 and 02 showed the engine converting for you, often by surprise. This lesson converts on purpose, in six programs that grow from that calculator core to one a working programmer would ship.

Seven converters on one table

Lesson 01 met three converters that take any value: Number(), String() and Boolean(). Real programs need four more. A radix is the base of a number system: 10 for everyday numbers, 2 for binary, 16 for hexadecimal.

ConverterTakesGives backExampleResult
Number(x)any valuea number; NaN unless the whole text is a numberNumber("12px")NaN
String(x)any valuea stringString(255)"255"
Boolean(x)any valuefalse for the falsy values, true for everything elseBoolean("0")true
parseInt(text, radix)text (anything else becomes text first)the whole number at the front; NaN if the front has no digitparseInt("12px", 10)12
parseFloat(text)textthe decimal number at the front, always base 10parseFloat("3.5kg")3.5
n.toFixed(digits)a numbera string with exactly that many decimals(1.005).toFixed(2)"1.00"
n.toString(radix)a numbera string written in that base(255).toString(16)"ff"

Read the third column again. toFixed and toString give back a string, not a number, and two of the three Common mistakes below come from forgetting it. So a converter's name tells you what it reads, and the table tells you what type comes out.

A whole string, or the front of one

Number() and parseInt() both turn text into a number, yet they disagree about "12px". The picture shows why.

Number reads the whole text, parseInt reads the front Number("12px"): the whole text is one number, or nothing 1 2 p x all four checked together: p and x refuse NaN refused parseInt("12px", 10): read from the left, stop at the first non-digit 1 2 p x read 1st read 2nd stop unread 12 the front Number asks: is this whole text a number? parseInt asks: what number does it start with?

So Number() fits a value that must be a number from end to end, like a form field. parseInt() and parseFloat() fit text that starts with a number and then says something else, like "12px" or "3.5kg".

Four converter forms

parseInt(text, 10)
parseFloat(text)
value.toFixed(digits)
new Intl.NumberFormat(locale).format(value)
  • text is the string to read. Anything else is turned into a string first.
  • 10 is the radix, the base to read in. This track always writes it.
  • digits is how many digits come after the point, from 0 to 100. The result is a string.
  • locale names a language and a region, such as "en-US" or "bn-BD". new builds a formatter, and Module 9 explains new.
Example 1: the calculator's core

New here: converting each token with Number() yourself. The starter's nextInt() is exactly Number(next()); here you see both halves. The program reads two numbers and an operator, all three as text.

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 = [];

const left = next();
const op = next();
const right = next();

const a = Number(left);
const b = Number(right);
const result = op === "+" ? a + b : a * b;

out.push("text joined: " + (left + right));
out.push("converted:   " + a + " " + op + " " + b + " = " + result);

console.log(out.join("\n"));
text joined: 75
converted:   7 + 5 = 12

That output is for the input 7 + 5. The first line is Bob's old bug, kept on purpose: two strings joined. The second converts first and adds numbers. The ternary handles + and *; Exercise 2 adds - and /.

Run in Compiler

Two isNaN functions, and the one that lies

A validator needs one question answered: did this text convert to a real number? NaN is the signal, and JavaScript has two functions that look for it. They answer different questions.

console.log(isNaN("abc"), Number.isNaN("abc"));
console.log(isNaN(""), Number.isNaN(""));
console.log(isNaN(undefined), Number.isNaN(undefined));
console.log(isNaN(NaN), Number.isNaN(NaN));
console.log(Number.isNaN(Number("abc")));
true false
false false
true false
true true
true

The global isNaN(x) runs Number(x) first, then checks. So isNaN("abc") says true about a value that is a string, not NaN. That is the lie: it answers "would this become NaN?" under a name that promises "is this NaN?". Number.isNaN(x) converts nothing, and it is true for the NaN value alone.

Line 2 fools both. Number("") is 0, so an empty field converts cleanly. A validator therefore converts with Number() on purpose, tests with Number.isNaN(), and checks for "" itself when empty input can arrive.

Example 2: which inputs are numbers?

New here: Number.isNaN() as the test, on a value you converted yourself.

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 = [];

const n = nextInt();
for (let i = 0; i < n; i++) {
  const text = next();
  const value = Number(text);
  out.push(text + ": " + (Number.isNaN(value) ? "not a number" : "the number " + value));
}

console.log(out.join("\n"));
12: the number 12
-4.5: the number -4.5
abc: not a number
1e3: the number 1000
0x1f: the number 31
12px: not a number
Infinity: the number Infinity

That output is for the input 7, then 12 -4.5 abc 1e3 0x1f 12px Infinity. 1e3 is 1 times 10 to the power 3, and 0x1f is hexadecimal for 31; Number() reads both. The last line is a gap: Infinity passes as a number. Exercise 1 closes it.

Run in Compiler

parseInt's radix, then and now

Before ES5, the 2009 edition of the standard, an engine was allowed to read a leading 0 as octal, base 8. Some old browsers did, so parseInt("08") gave 0, because 8 is not an octal digit. ES5 removed that rule, and Node 22 gives 8. The trap still alive today is 0x. Without a radix, parseInt reads "0x1f" as hexadecimal; with radix 10 it reads "0" and stops at the x.

Example 3: reading messy measurements

New here: parseInt with and without its radix, and parseFloat, side by side with Number().

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 = [];

const n = nextInt();
for (let i = 0; i < n; i++) {
  const text = next();
  out.push(text + ": Number " + Number(text) + ", parseInt " + parseInt(text) + ", base 10 " + parseInt(text, 10) + ", parseFloat " + parseFloat(text));
}

console.log(out.join("\n"));
12px: Number NaN, parseInt 12, base 10 12, parseFloat 12
3.5kg: Number NaN, parseInt 3, base 10 3, parseFloat 3.5
08: Number 8, parseInt 8, base 10 8, parseFloat 8
0x1f: Number 31, parseInt 31, base 10 0, parseFloat 0
1e3: Number 1000, parseInt 1, base 10 1, parseFloat 1000
abc: Number NaN, parseInt NaN, base 10 NaN, parseFloat NaN

That output is for the input 6, then 12px 3.5kg 08 0x1f 1e3 abc. Read it row by row. parseInt stops at the point in 3.5kg and at the e in 1e3, because neither is a digit. parseFloat reads both, but it never reads hexadecimal. A prefix reader stops early without any warning.

Run in Compiler

toFixed hands back text

toFixed(2) rounds a number to two decimals for a person to read. It gives back a string, and it rounds the value the computer stored, which is not always the value you typed. Module 1 printed 0.1 + 0.2 as 0.30000000000000004, because a number is stored as a binary fraction that is close, not exact. 1.005 is stored as 1.00499999999999989..., so it rounds down.

Example 4: a tip calculator

New here: toFixed, its string, and its rounding of the stored value.

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 = [];

const bill = Number(next());
const percent = Number(next());

const tip = bill * percent / 100;
const tipText = tip.toFixed(2);

out.push("tip:   " + tipText);
out.push("total: " + (bill + tip).toFixed(2));
out.push("typeof tipText: " + typeof tipText);
out.push("(1.005).toFixed(2): " + (1.005).toFixed(2));

console.log(out.join("\n"));
tip:   6.00
total: 46.00
typeof tipText: string
(1.005).toFixed(2): 1.00

That output is for the input 40 15, a bill of 40 and a 15 percent tip. The total adds two numbers, bill + tip, and only then calls toFixed. Arithmetic first, text last: that order is the rule, and lesson 04 shows a bill where breaking it costs a cent.

Run in Compiler
Example 5: the truthiness filter

New here: filter(Boolean), which the starter has used since Module 1. Lesson 01 named the falsy values: false, 0, "", null, undefined and NaN, plus -0 and 0n as two more forms of zero. This program prints every one, then five values that only look empty. check is a one-line arrow function, the same shape as the starter's next.

const out = [];
const check = (label, value) => out.push(label + ": " + (Boolean(value) ? "truthy" : "falsy"));

check("false", false);
check("0", 0);
check("-0", -0);
check("0n", 0n);
check("empty string", "");
check("null", null);
check("undefined", undefined);
check("NaN", NaN);
check("the text 0", "0");
check("the text false", "false");
check("one space", " ");
check("empty array", []);
check("empty object", {});

const mixed = [0, "0", "", " ", null, "12", NaN, []];
const kept = mixed.filter(Boolean);
out.push("kept " + kept.length + " of " + mixed.length);

console.log(out.join("\n"));
console.log(kept);
false: falsy
0: falsy
-0: falsy
0n: falsy
empty string: falsy
null: falsy
undefined: falsy
NaN: falsy
the text 0: truthy
the text false: truthy
one space: truthy
empty array: truthy
empty object: truthy
kept 4 of 8
[ '0', ' ', '12', [] ]

A string is falsy only when it is empty, so "0" and "false" are truthy. An array or an object is truthy even when empty. The last line is Node's display of an array, with strings in single quotes. So the starter's filter(Boolean) drops the empty strings at the edges of the input and keeps a token "0".

Run in Compiler

Intl.NumberFormat, your first built-in API

An API, an application programming interface, is a set of ready-made tools a system offers to your code. Intl is a built-in API of both browsers and Node, defined in its own standard, ECMA-402. Intl.NumberFormat knows each region's rules for digits, grouping and currency. The Playground's Node 22 carries the full data for every region, so "bn-BD" prints Bangla digits.

Example 6: a distance converter for two readers

New here: one number formatted three ways, with Intl.NumberFormat. Zara's delivery app logs trips in metres. Her report shows kilometres in English and in Bangla, the miles, and a fuel cost in US dollars.

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 = [];

const n = nextInt();
const en = new Intl.NumberFormat("en-US");
const bn = new Intl.NumberFormat("bn-BD");
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });

let totalKm = 0;
for (let i = 0; i < n; i++) {
  const metres = Number(next());
  const km = metres / 1000;
  totalKm += km;
  out.push(en.format(km) + " km | " + bn.format(km) + " km | " + en.format(km * 0.621371) + " mi");
}
out.push("total: " + en.format(totalKm) + " km");
out.push("fuel at $0.12 a km: " + usd.format(totalKm * 0.12));

console.log(out.join("\n"));
2.5 km | ২.৫ km | 1.553 mi
42.195 km | ৪২.১৯৫ km | 26.219 mi
1,234,567.891 km | ১২,৩৪,৫৬৭.৮৯১ km | 767,124.685 mi
total: 1,234,612.586 km
fuel at $0.12 a km: $148,153.51

That output is for the input 3, then 2500 42195 1234567891; the last trip is a truck's lifetime odometer. The same number reads 1,234,567.891 in en-US and ১২,৩৪,৫৬৭.৮৯১ in bn-BD, grouped in lakhs. The currency formatter adds the dollar sign and rounds to cents. Every format call returns a string, so formatting is the last step.

Run in Compiler

Where this is used

  • React Hook Form. A form field's value is always a string. The library's register option valueAsNumber: true hands your code a number, and its documentation says NaN comes back when the conversion fails.
  • Express. The Node web framework puts a URL's query string into req.query, so ?page=2 arrives as the string "2". Its documentation warns that these values are user input, to be checked before use.
  • Next.js Commerce. Vercel's open-source store template receives each price as a string. Its Price component converts it with parseFloat and formats it with Intl.NumberFormat in the currency style.

Common mistakes

1. Adding toFixed's text to a number.

const tip = 6;
const bill = 40;
const total = tip.toFixed(2) + bill;
console.log(total);
6.0040

No error, just a wrong answer. tip.toFixed(2) is the string "6.00", so + joins text. Write (tip + bill).toFixed(2): numbers first, text once at the end. You will make this mistake because "6.00" looks exactly like a number.

2. Calling toFixed on a token.

const price = "3.14159";
console.log(price.toFixed(2));
TypeError: price.toFixed is not a function

The Playground's verdict is Runtime error. price is a string, as every token from next() is, and strings have no toFixed. Write Number(price).toFixed(2). You will forget because the input looked like a number when you typed it.

3. parseInt on a price with a thousands comma.

const price = "1,299";
console.log(parseInt(price, 10));
1

No error, and the price is now 1. parseInt stops at the comma and returns the front. Number("1,299") is NaN, which at least fails loudly. Remove the commas first (Module 3 shows how), or ask for input without them. A loud NaN beats a quiet wrong 1.

Brain teaser

console.log(parseInt(0.0000005));
console.log(parseInt(0.000005));

On Node 22 this prints 5, then 0. The smaller number gives the bigger answer. Why?

The table says parseInt takes text, and anything else becomes text first. Which converter does that, and what does it make of a very small number?

Exercise 1Easy

Zara feeds Example 2 the token 1e400, which is too big for a number, and it says "the number Infinity". She wants a validator that treats endless values apart.

Input. A line with n, then n tokens with no spaces inside.

Output. One line per token: yes and String(Number(t)) when the value is a finite number, infinite when it is Infinity or -Infinity, and no when it is NaN.

Constraints. n is at least 1. A token is any text without spaces.

Sample. Input 5, then 12 abc Infinity -0 1e400, gives yes 12, no, infinite, yes 0 and infinite on five lines.

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"));

Not graded on its own. clean-numbers grades the base validator. Number.isFinite(x) is true only for a number that is neither NaN nor endless, and, like Number.isNaN, it converts nothing.

Run in Compiler
Exercise 2Medium

Grow Example 1 into Alice's full calculator. It reads many sums at once and answers each one the way JavaScript would.

Input. A line with n, then n lines a op b: three tokens, where op is one of +, -, * and /.

Output. For each line, the result of Number(a) op Number(b), printed as JavaScript prints it. Infinity, -Infinity and NaN are answers too.

Constraints. 1 <= n <= 10000. Each of a and b is a decimal number of 1 to 20 characters, maybe negative, maybe with a decimal point.

Sample. Input 5, then 7 + 5, 7 - 10, 0.1 * 3, 1 / 0 and 0 / 0 on five lines, gives 12, -3, 0.30000000000000004, Infinity and NaN on five lines.

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 calculator. The hidden tests divide by zero, and they catch a program that adds two tokens without converting them.

Run in Compiler
Exercise 3Hard

Kenji says the integer part of a number is the same however you take it. Test him with three methods: Math.trunc(Number(t)), which cuts off the decimals; parseInt(t, 10); and Math.floor(Number(t)), which rounds down.

Input. A line with n, then n tokens, each a number written as text.

Output. One line per token: the token, a colon and the three results in that order. Then same if all three are equal with ===, or differ.

Constraints. n is at least 1. Every token converts with Number() to a finite number.

Sample. Input 3, then 7.9 -7.9 1e21, gives 7.9: 7 7 7 same, -7.9: -7 -7 -8 differ and 1e21: 1e+21 1 1e+21 differ on three lines.

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"));

Not graded on its own. Find a token of your own, beyond the sample, that makes them differ. Which of the three would you trust for a price, and which for a page number?

Run in Compiler

Common doubts

  • Is +text the same as Number(text)?

    Yes. The unary plus runs the same conversion, so +"42" is 42. This track writes Number(text) because it says what it does. A reader can miss a lone +.

  • Why is Number("") 0 and not NaN?

    The standard says empty text, or text of only spaces, converts to 0. It is an old rule that cannot change now without breaking websites. So a validator checks for "" before it trusts the conversion.

  • Can I use toFixed to round money?

    Only to show it. It returns text, and it rounds the stored binary value, so (1.005).toFixed(2) is "1.00". Lesson 04 counts money in whole cents instead.

  • Why does bn-BD group the digits as 12,34,567?

    That is the South Asian lakh and crore grouping: three digits first, then pairs. Intl takes each region's rule from a shared database of locale data called CLDR, the Unicode Common Locale Data Repository. "en-IN" groups the same way with Latin digits.

Key takeaways

  • Number() wants the whole text to be a number; parseInt and parseFloat read the front and stop quietly.
  • Always give parseInt its radix: parseInt(text, 10). The old "08" trap is gone, but "0x1f" is still read as hexadecimal.
  • Test with Number.isNaN and Number.isFinite, which convert nothing; the global isNaN converts first and answers a different question.
  • toFixed, toString and Intl.NumberFormat all give back a string: do the arithmetic first and format once, at the end.
  • The falsy values are false, 0, "", null, undefined and NaN, plus -0 and 0n; "0", " ", [] and {} are truthy.
  • Go deeper: Interview Pack, output puzzles, the questions and the bug gallery (Pro).

Next, Kenji leaves a review comment that says "never use ==", on the one line where == was right, and lesson 04 settles it.

End of lesson 3

Mark it done, and your progress moves with you.

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