Emmanuel Akinfulubi

The language all 5 written

Values, numbers, text, and the equality rules JavaScript is famous for, run for real rather than memorised.

4 Values, types and variables

A program works with values — numbers, pieces of text, true and false — and gives them names so it can refer to them later. This lesson is about both, and about the eight kinds of value JavaScript has.

Naming a value

const and let

ran in node · runs here too

example.mjs

const city = "Lagos";
let visitors = 120;

console.log(city, visitors);

visitors = visitors + 30;      // let: the name can be pointed at a new value
console.log(city, visitors);

check("visitors went up by 30", visitors, 150);

What node printed, when the build ran it

node example.mjs

Lagos 120
Lagos 150
ok   visitors went up by 30

There are two ways to make a name. const is for a name that will always refer to the same value; let is for one you intend to reassign. Use const by default, and let only when you actually reassign. Then the word itself tells the next reader something true.

There is a third, var, which you will see in older code. It has scoping rules that surprise nearly everyone (lesson 10 shows how), and there is no reason to write it in new code.

Eight kinds of value

typeof tells you what kind of value you are holding.

typeof, for every kind

ran in node · runs here too

example.mjs

console.log(typeof 42);            // a number
console.log(typeof "hello");       // a string of text
console.log(typeof true);          // a boolean: true or false
console.log(typeof undefined);     // "no value was ever put here"
console.log(typeof null);          // "deliberately empty" -- see below
console.log(typeof 10n);           // a BigInt: a whole number of any size
console.log(typeof Symbol("id"));  // a unique identifier
console.log(typeof { a: 1 });      // an object: everything else
console.log(typeof [1, 2]);        // arrays are objects too
console.log(typeof function () {}); // and so are functions, with a special answer

check("a number", typeof 42, "number");
check("an array is an object", typeof [], "object");
check("and so, confusingly, is null", typeof null, "object");

What node printed, when the build ran it

node example.mjs

number
string
boolean
undefined
object
bigint
symbol
object
object
function
ok   a number
ok   an array is an object
ok   and so, confusingly, is null

Seven of those are primitives: number, string, boolean, undefined, null, bigint and symbol. The eighth is object, and it covers everything else — arrays, functions, dates, maps, and the objects you make yourself.

typeof null is "object", and that is a bug. It has been wrong since the first version of JavaScript in 1995, and it can never be fixed without breaking the web. To test for null, compare directly: value === null. To test for an array, Array.isArray(value).

A value has a type; a name does not

In JavaScript the type belongs to the value, not to the variable. The same let can hold a number and then a string, and nothing stops you.

One name, two types

ran in node · runs here too

example.mjs

let answer = 42;
console.log(typeof answer, answer);

answer = "forty-two";
console.log(typeof answer, answer);

check("the same name now holds a string", typeof answer, "string");

What node printed, when the build ran it

node example.mjs

number 42
string forty-two
ok   the same name now holds a string

That freedom is why the TypeScript lessons near the end of this course exist: most real bugs of the "undefined is not a function" kind are a value turning out to be a different type from the one the code assumed.

const fixes the name, not the value

This is the most misunderstood thing about const. It means the name can never be pointed at a different value. It does not mean the value cannot change inside.

Changing what a const refers to

ran in node · runs here too

example.mjs

const basket = ["apples"];
basket.push("pears");        // changing the array itself: allowed

console.log(basket);

check("a const array can still grow", basket.length, 2);

What node printed, when the build ran it

node example.mjs

[ 'apples', 'pears' ]
ok   a const array can still grow

Reassigning a const

dies with TypeError

example.mjs

const basket = ["apples"];
basket = ["pears"];          // pointing the name at a new array: not allowed

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:2
basket = ["pears"];          // pointing the name at a new array: not allowed
       ^

TypeError: Assignment to constant variable.
    at file:///app/example.mjs:2:8
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

So const protects you from accidentally reusing a name, which is the common mistake. If you need a value that cannot be changed at all, that is Object.freeze, covered in lesson 14.

undefined and null

Both mean "nothing here", in two different senses. undefined is what JavaScript gives you when a value was never set. null is what a programmer writes to say "this is deliberately empty".

Two kinds of nothing

ran in node · runs here too

example.mjs

let notYet;
const person = { name: "Ada", middleName: null };   // null: known to be empty

console.log(notYet);             // declared, never assigned
console.log(person.email);       // a property that does not exist
console.log(person.middleName);  // a property someone set to null on purpose

check("an unset let is undefined", notYet, undefined);
check("a missing property is undefined", person.email, undefined);
check("null had to be written by someone", person.middleName, null);

What node printed, when the build ran it

node example.mjs

undefined
undefined
null
ok   an unset let is undefined
ok   a missing property is undefined
ok   null had to be written by someone

Lesson 7 comes back to these, because the two behave differently in comparisons and in JSON, and because ?. and ?? exist specifically to deal with them.

Using a name before it exists

A const or let exists from the top of its block, but has no value until its line runs. Reading it before then is an error rather than undefined — which is a good thing, because it catches the mistake at the line where it happens.

Before the declaration

dies with ReferenceError

example.mjs

console.log("the limit is", limit);
const limit = 10;

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:1
console.log("the limit is", limit);
                            ^

ReferenceError: Cannot access 'limit' before initialization
    at file:///app/example.mjs:1:29
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Naming well

Names are case-sensitive (total and Total are different), may contain letters, digits, _ and $, and cannot start with a digit. The convention in JavaScript is camelCase for variables and functions, PascalCase for classes, and UPPER_SNAKE_CASE for values that are fixed configuration.

What a name may look like

ran in node · runs here too

example.mjs

const maxRetries = 3;                  // camelCase: a normal variable
const DEFAULT_TIMEOUT_MS = 5_000;      // a fixed setting; _ in a number is only for reading
const $price = 9.99;                   // legal, but unusual
const cafe = "café", café = "also fine";

console.log(maxRetries, DEFAULT_TIMEOUT_MS, $price, cafe, café);

check("the underscore does not change the number", DEFAULT_TIMEOUT_MS, 5000);
check("two names that differ by an accent are two names", cafe === café, false);

What node printed, when the build ran it

node example.mjs

3 5000 9.99 café also fine
ok   the underscore does not change the number
ok   two names that differ by an accent are two names
  • const by default; let when you reassign; never var.
  • Seven primitive types plus objects. typeof tells them apart — except that typeof null is "object", a bug that cannot be fixed.
  • Types belong to values, not names. A name can hold anything.
  • const fixes the name, not the contents: a const array can still be changed.
  • undefined means never set; null means deliberately empty.
  • Reading a let or const before its line throws a ReferenceError.

5 Numbers, and the one thing they cannot do

JavaScript has one kind of number, for whole numbers and fractions alike. It is fast and it is almost always what you want. It also cannot represent 0.1 exactly, and every surprise in this lesson follows from that one fact.

Arithmetic

The operators

ran in node · runs here too

example.mjs

console.log(7 + 2, 7 - 2, 7 * 2, 7 / 2);   // division is never whole-number division
console.log(7 % 2);                         // remainder
console.log(2 ** 10);                       // power
console.log(Math.trunc(7 / 2));             // whole-number part, if you want it

let score = 10;
score += 5;      // the same as score = score + 5
score *= 2;
score++;         // add one
console.log(score);

check("7 / 2 is not 3", 7 / 2, 3.5);
check("the running score", score, 31);

What node printed, when the build ran it

node example.mjs

9 5 14 3.5
1
1024
3
31
ok   7 / 2 is not 3
ok   the running score

0.1 + 0.2

The famous one

ran in node · runs here too

example.mjs

console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log((0.1).toFixed(20), "<- what 0.1 is really stored as");

check("0.1 + 0.2 is not 0.3", 0.1 + 0.2 === 0.3, false);

What node printed, when the build ran it

node example.mjs

0.30000000000000004
false
0.10000000000000000555 <- what 0.1 is really stored as
ok   0.1 + 0.2 is not 0.3

Numbers are stored in binary, and 0.1 has no exact binary form, for the same reason one-third has no exact decimal form. What is stored is the nearest value that fits, a tiny amount away. Add two of those and the tiny errors add up to something that no longer rounds back to 0.3.

This is not a JavaScript bug. It is how floating-point numbers work in almost every language — Python, Java, C and Go all print the same 0.30000000000000004. What matters is what you do about it.

What to do about it

ran in node · runs here too

example.mjs

// Never compare computed fractions with ===. Compare with a tolerance.
const close = (a, b) => Math.abs(a - b) < 1e-9;

console.log(close(0.1 + 0.2, 0.3));

// The error grows as you keep adding.
let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
console.log(total);

check("equal within a tolerance", close(0.1 + 0.2, 0.3), true);
check("ten tenths are not quite one", total === 1, false);
check("but they are close to it", close(total, 1), true);

What node printed, when the build ran it

node example.mjs

true
0.9999999999999999
ok   equal within a tolerance
ok   ten tenths are not quite one
ok   but they are close to it

Money is whole numbers

The standard fix for money is to never store fractions of the currency at all: store whole cents (or kobo, or pence), do all the arithmetic on whole numbers, which are exact, and divide only when you display the result.

Storing cents

ran in node · runs here too

example.mjs

const inPounds = 1.10 + 2.20;       // a £1.10 item and a £2.20 item
const inPence = 110 + 220;          // the same basket, in whole pence

console.log(inPounds);
console.log(inPence / 100);

check("adding the fractions is already wrong", inPounds === 3.3, false);
check("adding whole pence is exact", inPence, 330);

What node printed, when the build ran it

node example.mjs

3.3000000000000003
3.3
ok   adding the fractions is already wrong
ok   adding whole pence is exact

Rounding for display

toFixed and Math.round

ran in node · runs here too

example.mjs

console.log((0.1 + 0.2).toFixed(2));    // a string, rounded to 2 places
console.log((2.5).toFixed(0));
console.log((1.005).toFixed(2), "<- not 1.01");
console.log((1.005).toFixed(20), "<- because this is what 1.005 is");

console.log(Math.round(2.5), Math.round(-2.5));   // .5 goes up, towards +Infinity

check("toFixed returns a string", typeof (1.5).toFixed(1), "string");
check("1.005 rounds down, because it is slightly less than 1.005", (1.005).toFixed(2), "1.00");
check("Math.round(-2.5) goes up, to -2", Math.round(-2.5), -2);

What node printed, when the build ran it

node example.mjs

0.30
3
1.00 <- not 1.01
1.00499999999999989342 <- because this is what 1.005 is
3 -2
ok   toFixed returns a string
ok   1.005 rounds down, because it is slightly less than 1.005
ok   Math.round(-2.5) goes up, to -2

(1.005).toFixed(2) giving "1.00" looks like a rounding bug. It is the same storage problem: the value really held is 1.00499999…, which correctly rounds down. Round only at the moment you display a number, never in the middle of a calculation.

Formatting for people

Intl.NumberFormat

ran at build time

example.mjs

const gbp = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
const ngn = new Intl.NumberFormat("en-NG", { style: "currency", currency: "NGN" });
const german = new Intl.NumberFormat("de-DE");

console.log(gbp.format(1234.5));
console.log(ngn.format(1234.5));
console.log(german.format(1234567.891));

check("pounds, with a separator and two places", gbp.format(1234.5), "£1,234.50");
check("German uses . for thousands and , for decimals", german.format(1234567.891), "1.234.567,891");

What node printed, when the build ran it

node example.mjs

£1,234.50
₦1,234.50
1.234.567,891
ok   pounds, with a separator and two places
ok   German uses . for thousands and , for decimals

Intl formats numbers, currencies, dates and plurals for a given language and region, using data built into Node. Hand-writing thousands separators is a small bug factory; this is not.

Turning text into a number

Numbers arrive as text constantly — from a form, a file, a command-line argument, an environment variable. There are several ways to convert, and they disagree.

Number, parseInt, parseFloat

ran in node · runs here too

example.mjs

console.log(Number("42"), Number(" 42 "), Number("42px"), Number(""));
console.log(parseInt("42px"), parseInt("px42"), parseFloat("3.14abc"));
console.log(Number(null), Number(undefined), Number(true));

check("Number is strict: any junk gives NaN", Number("42px"), NaN);
check("parseInt reads digits until it cannot", parseInt("42px"), 42);
check("an empty string becomes 0, not NaN", Number(""), 0);

What node printed, when the build ran it

node example.mjs

42 42 NaN 0
42 NaN 3.14
0 NaN 1
ok   Number is strict: any junk gives NaN
ok   parseInt reads digits until it cannot
ok   an empty string becomes 0, not NaN

Number("") is 0. An empty form field or an unset variable that you convert with Number does not fail — it becomes zero, silently. Check for the empty string before converting anything that came from outside your program.

NaN, and Infinity

The special values

ran in node · runs here too

example.mjs

console.log(0 / 0, 1 / 0, -1 / 0);
console.log(typeof NaN);                  // NaN is a number that means "not a number"
console.log(NaN === NaN);                 // the only value not equal to itself
console.log(Number.isNaN(NaN), Number.isNaN("hello"));
console.log(isNaN("hello"), "<- the old global isNaN converts first");

check("NaN is not equal to itself", NaN === NaN, false);
check("Number.isNaN only says yes for NaN itself", Number.isNaN("hello"), false);
check("dividing by zero is Infinity, not an error", 1 / 0, Infinity);

What node printed, when the build ran it

node example.mjs

NaN Infinity -Infinity
number
false
true false
true <- the old global isNaN converts first
ok   NaN is not equal to itself
ok   Number.isNaN only says yes for NaN itself
ok   dividing by zero is Infinity, not an error

JavaScript does not throw on bad arithmetic. It produces NaN or Infinity and carries on, and a single NaN turns every calculation it touches into NaN. When a total on a page reads "NaN", the bug is upstream: find the first value that was not a number.

The largest safe integer

Whole numbers are exact — up to a point. Past 253 there are not enough bits to represent every integer, and the gaps start to show.

Where integers stop being exact

ran in node · runs here too

example.mjs

const big = Number.MAX_SAFE_INTEGER;
console.log(big);                 // 2 ** 53 - 1
console.log(big + 1);
console.log(big + 2, "<- should end in 3");

check("past the limit, adding 1 can do nothing", 2 ** 53 + 1 === 2 ** 53, true);
check("Number.isSafeInteger knows where the edge is", Number.isSafeInteger(big + 1), false);

What node printed, when the build ran it

node example.mjs

9007199254740991
9007199254740992
9007199254740992 <- should end in 3
ok   past the limit, adding 1 can do nothing
ok   Number.isSafeInteger knows where the edge is

Nine quadrillion sounds like a number you will never see. You will: database ids, Twitter-style snowflake ids, nanosecond timestamps and payment amounts in the smallest unit all get there. And the way it goes wrong in practice is quiet.

A large id, changed by JSON.parse

ran in node · runs here too

example.mjs

const sent = "9007199254740993";
const response = `{"orderId": ${sent}}`;
const order = JSON.parse(response);

console.log("sent:    ", sent);
console.log("received:", order.orderId);

check("JSON.parse silently changed the id", String(order.orderId) === sent, false);
check("into its nearest neighbour that fits", String(order.orderId), "9007199254740992");

What node printed, when the build ran it

node example.mjs

sent:     9007199254740993
received: 9007199254740992
ok   JSON.parse silently changed the id
ok   into its nearest neighbour that fits

No error, no warning: the id is simply a different id. Services that send ids this large send them as strings in JSON for exactly this reason, and you should keep them as strings.

BigInt, for integers of any size

BigInt

ran in node · runs here too

example.mjs

const exact = 9007199254740993n;          // the n makes it a BigInt
console.log(exact, exact + 2n);
console.log(2n ** 100n);
console.log(10n / 3n, "<- BigInt division drops the remainder");

check("BigInt keeps every digit", (exact + 2n).toString(), "9007199254740995");
check("and can go far past 2**53", (2n ** 100n).toString().length, 31);

What node printed, when the build ran it

node example.mjs

9007199254740993n 9007199254740995n
1267650600228229401496703205376n
3n <- BigInt division drops the remainder
ok   BigInt keeps every digit
ok   and can go far past 2**53

BigInt and Number do not mix

dies with TypeError

example.mjs

const quantity = 3n;
const price = 2.5;

console.log(quantity * price);

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:4
console.log(quantity * price);
                     ^

TypeError: Cannot mix BigInt and other types, use explicit conversions
    at file:///app/example.mjs:4:22
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

JavaScript refuses to mix the two silently, because any automatic conversion would lose either the fraction or the precision. Convert deliberately — Number(big) or BigInt(small) — and know which one you are giving up. BigInt also cannot go into JSON; the error catalogue shows what happens when you try.

  • One number type, a 64-bit float. 0.1 + 0.2 is 0.30000000000000004 in almost every language.
  • Compare computed fractions with a tolerance, never ===. Store money as whole cents.
  • Round only for display. toFixed returns a string, and 1.005 rounds down because of how it is stored.
  • Number() is strict, parseInt is lenient, and Number("") is 0.
  • Bad arithmetic gives NaN or Infinity, not an error. Use Number.isNaN.
  • Integers are exact only to 253. Larger ids get silently changed by JSON.parse; keep them as strings or use BigInt.

6 Strings and template literals

A string is a piece of text. Almost every program spends most of its time moving text around — reading it, checking it, building new text out of old — so the dozen methods in this lesson are among the ones you will use most.

Three kinds of quote

Quotes

ran in node · runs here too

example.mjs

const single = 'single quotes';
const double = "double quotes";
const backtick = `backticks, which can hold ${1 + 1} expressions`;

console.log(single);
console.log(double);
console.log(backtick);
console.log("it's easy to include the other kind", 'she said "hi"');

check("backticks run the code inside ${}", backtick.includes("hold 2 expressions"), true);

What node printed, when the build ran it

node example.mjs

single quotes
double quotes
backticks, which can hold 2 expressions
it's easy to include the other kind she said "hi"
ok   backticks run the code inside ${}

Single and double quotes are identical; pick one and be consistent (this course uses double). Backticks make a template literal, which does two extra things: anything inside ${ } is evaluated and inserted, and the string may span several lines.

A template literal

ran in node · runs here too

example.mjs

const user = { name: "Ada", unread: 3 };

const message = `Hello ${user.name},
you have ${user.unread} new message${user.unread === 1 ? "" : "s"}.`;

console.log(message);

check("the plural was worked out inside the template", message.endsWith("3 new messages."), true);
check("and the line break is really in the string", message.split("\n").length, 2);

What node printed, when the build ran it

node example.mjs

Hello Ada,
you have 3 new messages.
ok   the plural was worked out inside the template
ok   and the line break is really in the string

The methods you will actually use

The everyday toolkit

ran in node · runs here too

example.mjs

const s = "  Hello, World  ";

console.log(JSON.stringify(s.trim()));          // strip spaces at both ends
console.log(s.trim().toUpperCase(), s.trim().toLowerCase());
console.log(s.includes("World"), s.startsWith("  He"), s.trim().endsWith("World"));
console.log(s.trim().indexOf("o"), s.trim().lastIndexOf("o"), s.indexOf("xyz"));
console.log(s.trim().replace("World", "Node"));
console.log("a-b-c".replaceAll("-", "+"));
console.log("a,b,,c".split(","));
console.log(["x", "y", "z"].join(" / "));
console.log("7".padStart(3, "0"), "ab".repeat(3));

check("indexOf gives -1 when it is not there", s.indexOf("xyz"), -1);
check("split keeps the empty piece between two commas", "a,b,,c".split(","), ["a", "b", "", "c"]);
check("padStart for fixed-width numbers", "7".padStart(3, "0"), "007");

What node printed, when the build ran it

node example.mjs

"Hello, World"
HELLO, WORLD hello, world
true true true
4 8 -1
Hello, Node
a+b+c
[ 'a', 'b', '', 'c' ]
x / y / z
007 ababab
ok   indexOf gives -1 when it is not there
ok   split keeps the empty piece between two commas
ok   padStart for fixed-width numbers

Every one of those returns a new string. Strings in JavaScript can never be changed in place; methods that look like they modify one actually build another.

Strings cannot be changed in place

ran in node · runs here too

example.mjs

let word = "node";
word.toUpperCase();              // builds "NODE" -- and throws it away
console.log(word);

word = word.toUpperCase();       // keep the result by assigning it
console.log(word);

check("the original was never modified", "node".toUpperCase() === "node", false);
check("you have to keep what the method returns", word, "NODE");

What node printed, when the build ran it

node example.mjs

node
NODE
ok   the original was never modified
ok   you have to keep what the method returns

Pieces of a string

Indexing and slicing

ran in node · runs here too

example.mjs

const path = "reports/2026/q3.csv";

console.log(path[0], path.at(-1));           // first character, last character
console.log(path.slice(0, 7));                // from 0 up to (not including) 7
console.log(path.slice(-6));                  // the last six characters
console.log(path.slice(path.lastIndexOf(".") + 1));   // everything after the last dot

check("at(-1) counts from the end", path.at(-1), "v");
check("slice with a negative number counts from the end", path.slice(-6), "q3.csv");
check("the file extension", path.slice(path.lastIndexOf(".") + 1), "csv");

What node printed, when the build ran it

node example.mjs

r v
reports
q3.csv
csv
ok   at(-1) counts from the end
ok   slice with a negative number counts from the end
ok   the file extension

Comparing and sorting text

Comparison is by character code

ran in node · runs here too

example.mjs

const fruit = ["banana", "apple", "Cherry"];

console.log("apple" < "banana", "Zebra" < "apple");
console.log([...fruit].sort());
console.log([...fruit].sort((a, b) => a.localeCompare(b)));

check("capitals sort before all lowercase letters", "Zebra" < "apple", true);
check("so a plain sort puts Cherry first", [...fruit].sort(), ["Cherry", "apple", "banana"]);
check("localeCompare sorts the way a person would",
      [...fruit].sort((a, b) => a.localeCompare(b)), ["apple", "banana", "Cherry"]);

What node printed, when the build ran it

node example.mjs

true true
[ 'Cherry', 'apple', 'banana' ]
[ 'apple', 'banana', 'Cherry' ]
ok   capitals sort before all lowercase letters
ok   so a plain sort puts Cherry first
ok   localeCompare sorts the way a person would

< compares strings character by character using each character's numeric code, and every capital letter has a smaller code than every lowercase one. So a plain sort() puts "Zebra" before "apple". localeCompare compares the way a dictionary would, and is what to use for anything a person will read.

How long is a string?

This is the question with three different answers.

length counts UTF-16 units

ran in node · runs here too

example.mjs

const plain = "hello";
const thumbs = "👍";
const accent = "é";

console.log(plain.length);
console.log(thumbs.length, "<- one emoji, length 2");
console.log([...thumbs].length, "<- spreading counts it once");

check("five letters, length 5", plain.length, 5);
check("an emoji is two UTF-16 units", thumbs.length, 2);
check("but one code point", [...thumbs].length, 1);

What node printed, when the build ran it

node example.mjs

5
2 <- one emoji, length 2
1 <- spreading counts it once
ok   five letters, length 5
ok   an emoji is two UTF-16 units
ok   but one code point

JavaScript strings are stored as UTF-16, and .length counts the 16-bit units, not the characters. Most characters in most languages fit in one unit. Emoji, and characters from a number of scripts, need two. Spreading a string into an array, as [...text] does, splits it by code point instead, which counts that emoji once.

And even code points are not what a person means by "a character".

Three different lengths

ran at build time

example.mjs

const family = "👩‍👩‍👧";
const composed = "é";            // one code point
const decomposed = "e\u0301";    // e, plus a combining accent

console.log(family, family.length, [...family].length);
console.log(composed, decomposed, composed === decomposed);
console.log(composed.normalize() === decomposed.normalize());

const graphemes = [...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(family)];
console.log("characters a person would count:", graphemes.length);

check("the family emoji is 8 UTF-16 units", family.length, 8);
check("and 5 code points, joined together", [...family].length, 5);
check("but one grapheme -- what a person sees", graphemes.length, 1);
check("two ways of writing é are different strings", composed === decomposed, false);
check("until you normalize them", composed.normalize() === decomposed.normalize(), true);

What node printed, when the build ran it

node example.mjs

👩‍👩‍👧 8 5
é é false
true
characters a person would count: 1
ok   the family emoji is 8 UTF-16 units
ok   and 5 code points, joined together
ok   but one grapheme -- what a person sees
ok   two ways of writing é are different strings
ok   until you normalize them

Which length do you want? For storage limits and protocols, the one the system counts — usually bytes, which is lesson 30. For "truncate this name to 20 characters" in a user interface, graphemes, with Intl.Segmenter; slicing by .length can cut an emoji in half and leave a broken character on screen. And before comparing text a person typed, normalize() it, or two identical-looking strings will not match.

Escapes

Special characters

ran in node · runs here too

example.mjs

console.log("line one\nline two");
console.log("a\ttab");
console.log("a backslash: \\");
console.log(String.raw`C:\new\temp`, "<- String.raw leaves backslashes alone");

check("\\n is one character, a newline", "\n".length, 1);
check("String.raw keeps the backslash", String.raw`\n`.length, 2);

What node printed, when the build ran it

node example.mjs

line one
line two
a	tab
a backslash: \
C:\new\temp <- String.raw leaves backslashes alone
ok   \n is one character, a newline
ok   String.raw keeps the backslash
  • Three kinds of quote. Backticks make template literals: ${expression}, and multiple lines.
  • String methods never change the string; they return a new one. Keep the result.
  • slice and at take negative numbers to count from the end.
  • < and a plain sort() compare character codes. Use localeCompare for text people read.
  • .length counts UTF-16 units. [...s].length counts code points. Intl.Segmenter counts what a person sees.
  • normalize() before comparing text that came from a person.

7 Truthiness, equality, and null versus undefined

"Are these two the same?" and "is there anything here?" are the two questions a program asks most often. JavaScript has more than one way to ask each, and choosing the right one avoids a whole family of bugs.

=== and ==

Strict and loose equality

ran in node · runs here too

example.mjs

console.log(1 === 1, "1" === "1");
console.log(1 === "1");     // different types: never equal with ===
console.log(1 == "1");      // == converts first, then compares
console.log(0 == "", 0 == "0", "" == "0");
console.log(null == undefined, null === undefined);

check("=== never converts", 1 === "1", false);
check("== converts, so these are 'equal'", 0 == "", true);
check("but it is not even consistent with itself", "" == "0", false);

What node printed, when the build ran it

node example.mjs

true true
false
true
true true false
true false
ok   === never converts
ok   == converts, so these are 'equal'
ok   but it is not even consistent with itself

=== is strict equality: two values are equal only if they are the same type and the same value. == is loose equality: if the types differ it converts one or both first, by rules long enough that almost nobody remembers them. The result is that 0 == "" and 0 == "0" are both true, yet "" == "0" is false.

Always use === and !==. The one common exception is value == null, which is true for exactly null and undefined and nothing else — a compact way to ask "is there nothing here, of either kind". Everything else, strict.

The ones everybody laughs at

Coercion, at its worst

ran in node · runs here too

example.mjs

console.log([] == false);
console.log([] == ![]);
console.log("5" + 3, "5" - 3);
console.log(true + true);
console.log([] + {});

check("an empty array is loosely equal to false", [] == false, true);
check("+ joins if either side is a string", "5" + 3, "53");
check("- always does arithmetic", "5" - 3, 2);

What node printed, when the build ran it

node example.mjs

true
true
53 2
2
[object Object]
ok   an empty array is loosely equal to false
ok   + joins if either side is a string
ok   - always does arithmetic

These are all the same mechanism — converting values so that an operator can work on them — and none of them appears in code written with === and deliberate conversions. The only one worth remembering is +: if either side is a string, it joins text instead of adding. Convert with Number() first when a value came from outside your program.

Objects are compared by identity

Same contents, different objects

ran in node · runs here too

example.mjs

const a = { id: 1 };
const b = { id: 1 };
const c = a;

console.log(a === b);     // two separate objects that happen to look alike
console.log(a === c);     // two names for the same object
console.log([1, 2] === [1, 2]);

check("equal-looking objects are not equal", a === b, false);
check("the same object is", a === c, true);

What node printed, when the build ran it

node example.mjs

false
true
false
ok   equal-looking objects are not equal
ok   the same object is

=== on two objects asks "is this the same object?", not "do these contain the same things?". Comparing contents is a different operation, called deep equality. It is what the check() on every panel does, using Node's own util.isDeepStrictEqual. Lesson 14 explains why this is not a quirk but the whole model of how objects work.

What counts as true

An if accepts any value, not just true or false. Every value is either truthy or falsy, and there are exactly eight falsy ones. Everything else is truthy.

The eight falsy values

ran in node · runs here too

example.mjs

// Each value, paired with how you would write it.
const falsy = [
  ["false", false], ["0", 0], ["-0", -0], ["0n", 0n],
  ['""', ""], ["null", null], ["undefined", undefined], ["NaN", NaN],
];
for (const [written, value] of falsy) {
  console.log(written.padEnd(9), "->", Boolean(value));
}

const surprising = ["0", "false", " ", [], {}];
console.log();
console.log("but all of these are truthy:");
for (const value of surprising) console.log(" ", JSON.stringify(value), "->", Boolean(value));

check("all eight are falsy", falsy.every(([, value]) => !value), true);
check("the string \"0\" is truthy", Boolean("0"), true);
check("so is an empty array", Boolean([]), true);

What node printed, when the build ran it

node example.mjs

false     -> false
0         -> false
-0        -> false
0n        -> false
""        -> false
null      -> false
undefined -> false
NaN       -> false

but all of these are truthy:
  "0" -> true
  "false" -> true
  " " -> true
  [] -> true
  {} -> true
ok   all eight are falsy
ok   the string "0" is truthy
ok   so is an empty array

The two that catch people are on the second list. "0" and "false" are non-empty strings, so they are truthy — which matters the moment you read a setting from a file or an environment variable, where everything is a string. And an empty array is truthy: to ask whether an array has anything in it, ask list.length > 0.

Defaults: || versus ??

A common need is "use this value, or a default if there is none". There are two operators for it, and the older one has a trap.

|| and ??, on a zero

ran in node · runs here too

example.mjs

function pageSize(requested) {
  return {
    withOr: requested || 20,          // any falsy value gets the default
    withNullish: requested ?? 20,     // only null or undefined gets the default
  };
}

console.log("not given: ", pageSize(undefined));
console.log("asked for 0:", pageSize(0));
console.log("asked for 5:", pageSize(5));

check("|| threw away a real 0", pageSize(0).withOr, 20);
check("?? kept it", pageSize(0).withNullish, 0);
check("both default when nothing was given", pageSize(undefined), { withOr: 20, withNullish: 20 });

What node printed, when the build ran it

node example.mjs

not given:  { withOr: 20, withNullish: 20 }
asked for 0: { withOr: 20, withNullish: 0 }
asked for 5: { withOr: 5, withNullish: 5 }
ok   || threw away a real 0
ok   ?? kept it
ok   both default when nothing was given

a || b gives b whenever a is falsy — including 0, "" and false, which are often perfectly good values. a ?? b, the nullish coalescing operator, gives b only when a is null or undefined. For defaults, ?? is nearly always what you mean.

Optional chaining: ?.

Reaching through something missing

dies with TypeError

example.mjs

const order = { id: 7 };                // no customer on this one
console.log(order.customer.name);

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:2
console.log(order.customer.name);
                           ^

TypeError: Cannot read properties of undefined (reading 'name')
    at file:///app/example.mjs:2:28
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

?. and ?? together

ran in node · runs here too

example.mjs

const orders = [
  { id: 7 },
  { id: 8, customer: { name: "Ada" } },
];

for (const order of orders) {
  console.log(order.id, order.customer?.name ?? "(guest)");
}

check("?. gives undefined instead of throwing", orders[0].customer?.name, undefined);
check("and pairs naturally with ??", orders[0].customer?.name ?? "(guest)", "(guest)");

What node printed, when the build ran it

node example.mjs

7 (guest)
8 Ada
ok   ?. gives undefined instead of throwing
ok   and pairs naturally with ??

a?.b means "if a is null or undefined, stop and give undefined; otherwise read b". It works for calls too: callback?.() calls the function only if there is one. Use it where a value is genuinely optional — not everywhere, or a real bug turns into a silent undefined.

null and undefined, side by side

How the two differ

ran in node · runs here too

example.mjs

const profile = { name: "Ada", nickname: null, age: undefined };

console.log(JSON.stringify(profile));      // undefined disappears; null survives
console.log("age" in profile, profile.age);
console.log(null == undefined, null === undefined);
console.log(typeof null, typeof undefined);

check("JSON drops undefined properties", JSON.parse(JSON.stringify(profile)), { name: "Ada", nickname: null });
check("the property exists, holding undefined", "age" in profile, true);

What node printed, when the build ran it

node example.mjs

{"name":"Ada","nickname":null}
true undefined
true false
object undefined
ok   JSON drops undefined properties
ok   the property exists, holding undefined

The practical difference: undefined is what JavaScript produces on its own — a missing property, an argument nobody passed, a function with no return. null is what you write to say "empty, on purpose", and it survives a trip through JSON where undefined does not. If you need to send "no nickname" to a server, send null.

  • Use === and !==. == null is the one useful loose comparison.
  • + joins text if either side is a string. Convert outside input with Number() first.
  • Objects compare by identity. Comparing contents is deep equality.
  • Eight falsy values: false 0 -0 0n "" null undefined NaN. "0" and [] are truthy.
  • ?? for defaults, not ||, unless you really mean "any falsy value".
  • ?. reads through something that may be missing. undefined vanishes in JSON; null does not.

8 if, switch and loops

Programs decide and repeat. if and switch decide; for and while repeat. The syntax is shared with C, Java and most languages you might meet next, and so are a couple of its traps.

if, else if, else

Choosing a branch

ran in node · runs here too

example.mjs

function grade(score) {
  if (score >= 70) {
    return "distinction";
  } else if (score >= 50) {
    return "pass";
  } else {
    return "not yet";
  }
}

console.log(grade(82), grade(55), grade(12));

check("the first true branch wins", grade(82), "distinction");
check("boundaries are inclusive with >=", grade(50), "pass");

What node printed, when the build ran it

node example.mjs

distinction pass not yet
ok   the first true branch wins
ok   boundaries are inclusive with >=

The conditions are checked top to bottom, and the first true one wins — so order them from most specific to least. For a choice between two values, the conditional operator is shorter: condition ? ifTrue : ifFalse.

The conditional operator

ran in node · runs here too

example.mjs

const items = 1;
const label = items === 1 ? "item" : "items";
console.log(`${items} ${label}`);

check("the ternary picked the singular", label, "item");

What node printed, when the build ran it

node example.mjs

1 item
ok   the ternary picked the singular

switch, and falling through

The bug switch is famous for

ran in node · runs here too

example.mjs

function shippingDays(method) {
  let days;
  switch (method) {
    case "express":
      days = 1;
    case "standard":        // no break above: express falls into here
      days = 5;
      break;
    default:
      days = 10;
  }
  return days;
}

console.log("express: ", shippingDays("express"), "<- should be 1");
console.log("standard:", shippingDays("standard"));

check("a missing break made express as slow as standard", shippingDays("express"), 5);

What node printed, when the build ran it

node example.mjs

express:  5 <- should be 1
standard: 5
ok   a missing break made express as slow as standard

Once a case matches, switch keeps running every line below it until it hits a break. Forgetting one is a classic bug. The cleanest way to avoid it is to not need break at all: return from each case, or use an object as a lookup table.

A lookup table instead

ran in node · runs here too

example.mjs

const DAYS = { express: 1, standard: 5 };
const shippingDays = (method) => DAYS[method] ?? 10;

console.log(shippingDays("express"), shippingDays("standard"), shippingDays("pigeon"));

check("express is 1 day", shippingDays("express"), 1);
check("anything unknown gets the default", shippingDays("pigeon"), 10);

What node printed, when the build ran it

node example.mjs

1 5 10
ok   express is 1 day
ok   anything unknown gets the default

Loops

for, for...of, while

ran in node · runs here too

example.mjs

// Counting: start; keep going while; step.
for (let i = 1; i <= 3; i++) {
  console.log("lap", i);
}

// Over the values in an array.
for (const colour of ["red", "green"]) {
  console.log("colour:", colour);
}

// While a condition holds.
let balance = 100;
let months = 0;
while (balance < 150) {
  balance *= 1.1;
  months++;
}
console.log(`${months} months to grow past 150`);

check("5 months at 10%", months, 5);

What node printed, when the build ran it

node example.mjs

lap 1
lap 2
lap 3
colour: red
colour: green
5 months to grow past 150
ok   5 months at 10%

for...of is the loop to reach for: it goes through the values of anything iterable — arrays, strings, maps, sets — without an index to get wrong. Use the three-part for when you actually need the number, and while when you do not know in advance how many times it will run.

break and continue

ran in node · runs here too

example.mjs

const readings = [12, -1, 40, 999, 7];
const kept = [];

for (const r of readings) {
  if (r < 0) continue;      // skip this one, carry on with the next
  if (r > 100) break;       // stop the loop entirely
  kept.push(r);
}

console.log(kept);

check("continue skipped -1, break stopped at 999", kept, [12, 40]);

What node printed, when the build ran it

node example.mjs

[ 12, 40 ]
ok   continue skipped -1, break stopped at 999

for...in is not for arrays

There is a second loop that looks almost the same, for...in, and it does something different: it goes through the keys of an object, as strings.

for...in on an array

ran in node · runs here too

example.mjs

const scores = [10, 20, 30];

for (const i in scores) {
  console.log(i, typeof i, "next index would be", i + 1);
}

check("for...in hands back the keys as strings", Object.keys(scores), ["0", "1", "2"]);
check("so i + 1 joins text", "0" + 1, "01");

What node printed, when the build ran it

node example.mjs

0 string next index would be 01
1 string next index would be 11
2 string next index would be 21
ok   for...in hands back the keys as strings
ok   so i + 1 joins text

On an array that gives you string indices, so arithmetic on them joins text. It also walks keys a library added to the array, and keys inherited from a parent object. The rule is simple: for...of for arrays, and for objects, loop over Object.entries.

Looping over an object

ran in node · runs here too

example.mjs

const stock = { apples: 12, pears: 0, plums: 7 };

for (const [fruit, count] of Object.entries(stock)) {
  console.log(fruit.padEnd(7), count);
}

const inStock = Object.entries(stock).filter(([, count]) => count > 0).map(([fruit]) => fruit);

check("entries gives [key, value] pairs", Object.entries(stock)[0], ["apples", 12]);
check("and pears are out of stock", inStock, ["apples", "plums"]);

What node printed, when the build ran it

node example.mjs

apples  12
pears   0
plums   7
ok   entries gives [key, value] pairs
ok   and pears are out of stock

A loop that never ends

Every while needs something inside it that eventually makes the condition false. When you forget, the program does not crash — it simply never finishes, and in Node that means the terminal sits there until you press Ctrl+C. (The panels that run in this page have a guard that stops a runaway loop after two million turns, so an edited panel cannot freeze your tab.)

The line that ends it

ran in node · runs here too

example.mjs

let attempts = 0;
let connected = false;

while (!connected) {
  attempts++;
  if (attempts === 3) connected = true;   // without this line, it runs forever
}

console.log("connected after", attempts, "attempts");

check("the loop ended because something inside changed the condition", attempts, 3);

What node printed, when the build ran it

node example.mjs

connected after 3 attempts
ok   the loop ended because something inside changed the condition
  • The first true branch of an if ... else if chain wins; order conditions from most specific.
  • switch falls through without break. A lookup object often replaces it.
  • for...of for values; the counting for when you need the index; while for an unknown number of turns.
  • for...in gives string keys and inherited ones. Use Object.entries for objects instead.
  • Every loop needs a way out. break stops it; continue skips to the next turn.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.