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
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.
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.
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.
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
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".
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.
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.
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
constby default;letwhen you reassign; nevervar.- Seven primitive types plus objects.
typeoftells them apart — except thattypeof nullis"object", a bug that cannot be fixed. - Types belong to values, not names. A name can hold anything.
constfixes the name, not the contents: aconstarray can still be changed.undefinedmeans never set;nullmeans deliberately empty.- Reading a
letorconstbefore its line throws aReferenceError.
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
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
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.
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.
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
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
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.
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
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.
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.
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
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
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.2is0.30000000000000004in almost every language. - Compare computed fractions with a tolerance, never
===. Store money as whole cents. - Round only for display.
toFixedreturns a string, and1.005rounds down because of how it is stored. Number()is strict,parseIntis lenient, andNumber("")is0.- Bad arithmetic gives
NaNorInfinity, not an error. UseNumber.isNaN. - Integers are exact only to 253. Larger ids get silently changed by
JSON.parse; keep them as strings or useBigInt.
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
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.
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
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.
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
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
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.
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".
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
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.
sliceandattake negative numbers to count from the end.<and a plainsort()compare character codes. UselocaleComparefor text people read..lengthcounts UTF-16 units.[...s].lengthcounts code points.Intl.Segmentercounts 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 ==
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
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
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.
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.
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: ?.
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
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
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!==.== nullis the one useful loose comparison. +joins text if either side is a string. Convert outside input withNumber()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.undefinedvanishes in JSON;nulldoes 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
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.
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
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.
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
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.
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.
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.
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.)
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 ifchain wins; order conditions from most specific. switchfalls through withoutbreak. A lookup object often replaces it.for...offor values; the countingforwhen you need the index;whilefor an unknown number of turns.for...ingives string keys and inherited ones. UseObject.entriesfor objects instead.- Every loop needs a way out.
breakstops it;continueskips to the next turn.