3. JavaScript Language Core
JavaScript is the only programming language that every browser runs natively, and through Node.js it also runs on servers, build tools and command-line programs. This chapter covers the core of the language itself, independent of any browser API: its values and types, variables and scope, operators and control flow, functions and closures, error handling and the module systems used to organise code into files. Nearly every confusing behaviour you will meet later in DOM scripting, asynchronous code and frameworks can be traced back to one of these fundamentals, so they are worth learning precisely.
<a id="31-types-values-and-variables"></a>
3.1 Types, Values and Variables
This subchapter covers what kinds of values JavaScript has, how variables hold them, where those variables are visible, and the rules the language follows when it converts one type into another.
Primitive Types and Wrapper Objects
Before you can reason about any JavaScript program, you need to know what kind of thing a value is. The kind decides how the value is copied, how it is compared, and whether it can be changed. JavaScript splits every value into two families: primitives, which are simple and immutable, and objects, which are collections of properties that can be shared and modified.
The seven primitive types. A primitive is a value that is not an object and has no properties of its own.
| Type | Example values | typeof result | Notes |
|---|---|---|---|
string | "hello", 'a', `x` | "string" | Immutable sequence of UTF-16 code units |
number | 42, 3.14, NaN, Infinity | "number" | 64-bit floating point, the only "ordinary" numeric type |
bigint | 10n, 2n ** 64n | "bigint" | Arbitrary-precision integers (ES2020) |
boolean | true, false | "boolean" | |
undefined | undefined | "undefined" | "No value has been assigned" |
null | null | "object" | "Intentionally empty". The typeof result is a historical bug. |
symbol | Symbol("id") | "symbol" | Unique identifiers, mainly used as property keys (ES2015) |
Everything else is an object: plain objects, arrays, functions, dates, regular expressions, maps, DOM nodes, errors. typeof returns "object" for all of them except functions, which return "function".
Values versus references. A variable that holds a primitive holds the value itself. A variable that holds an object holds a reference to an object that lives elsewhere in memory. Copying a variable copies whatever it holds.
let a = 7; let p = { x: 1 };
let b = a; let q = p;
a: 7 p: ref --+
b: 7 (own copy) +--> { x: 1 } <- one object, two references
q: ref --+
b = 8 -> a is still 7 q.x = 2 -> p.x is also 2
Primitives are also immutable: no operation can change a primitive in place. "abc".toUpperCase() returns a new string. Reassigning a variable (s = s + "!") makes it point to a different value; the original value is untouched. Chapter 4 covers object references, copying and cloning in depth.
Wrapper objects and autoboxing. If primitives have no properties, how does "abc".length work? When you access a property on a string, number, boolean, symbol or bigint, the engine temporarily wraps the primitive in the matching wrapper object (String, Number, Boolean, Symbol, BigInt), reads the property from it (usually a method inherited from String.prototype and so on), then discards the wrapper. This is called autoboxing. Engines optimise the wrapper away, but the behaviour is as if it existed.
"abc".toUpperCase()
|
+-> new String("abc") (temporary wrapper, conceptually)
|
+-> String.prototype.toUpperCase -> "ABC"
wrapper discarded
Because the wrapper is thrown away, properties you assign to a primitive are lost. In strict mode (3.4) the assignment throws instead of failing silently. null and undefined have no wrappers, so accessing any property on them throws a TypeError.
Calling wrappers with and without new.
| Expression | Result | Use it? |
|---|---|---|
Number("42") | 42 (primitive) | Yes: explicit conversion |
String(123) | "123" (primitive) | Yes |
Boolean("") | false (primitive) | Yes |
new Number(42) | A Number object | No |
new Boolean(false) | A Boolean object, which is truthy | No |
new Symbol() / new BigInt(1) | TypeError | Not allowed |
Example: primitives, wrappers and their traps
"use strict";
const word = "hello";
console.log(word.length); // 5: autoboxed to a String wrapper, then discarded
console.log(word.toUpperCase()); // "HELLO": a new string; word is unchanged
console.log(word); // "hello"
try {
word.custom = 1; // assigning a property to a primitive
} catch (e) {
console.log(e.name); // "TypeError" in strict mode (silently ignored in sloppy mode)
}
const n1 = 42;
const n2 = new Number(42); // an object wrapping 42: avoid
console.log(typeof n1, typeof n2); // "number" "object"
console.log(n1 === n2); // false: primitive vs object
console.log(n1 == n2); // true: == unwraps the object (see Type Coercion)
const flag = new Boolean(false);
if (flag) console.log("runs!"); // runs: every object is truthy, even this one
console.log(typeof null); // "object": a bug kept for backward compatibility
console.log(typeof function () {});// "function"
console.log(typeof []); // "object": use Array.isArray to detect arrays
Detecting types reliably. typeof is correct for every primitive except null. For the rest, use dedicated checks:
const isNull = (v) => v === null;
const isObject = (v) => v !== null && typeof v === "object"; // excludes null and functions
Array.isArray([1, 2]); // true
Object.prototype.toString.call(new Date()); // "[object Date]": works for built-ins
Key Takeaways
- JavaScript has seven primitive types (
string,number,bigint,boolean,undefined,null,symbol). Everything else is an object. - Primitives are immutable and copied by value. Variables holding objects hold references, so copies share the same object.
- Accessing a property on a primitive autoboxes it in a temporary wrapper object. Properties assigned to primitives are lost.
- Use
Number(),String()andBoolean()withoutnewfor conversion. Never create wrapper objects withnew. typeof null === "object"is a historical bug. Test fornullwith=== null.
Practice
- (Beginner) Predict the
typeofresult for:0n,NaN,null,[],class {},Symbol.iterator,undefined,new String(""). Then check your answers in the browser console.- (Intermediate) Write a function
typeName(value)that returns"null","array","date","regexp","function"or thetypeofresult for anything else. Explain whyObject.prototype.toString.callis more reliable thaninstanceoffor objects that come from an<iframe>.- (Interview) "If strings are primitives and have no properties, how does
'abc'.lengthwork, and why doess.foo = 1; s.fooreturnundefined?" Hint: describe what happens to the object the engine creates for the property access.
Numbers, BigInt and Numeric Precision
Most languages offer several numeric types: 8-, 16-, 32- and 64-bit integers, single and double floats. JavaScript originally offered exactly one, number, a 64-bit floating-point value. That choice keeps the language simple, but it means integers lose precision above a certain size and decimal fractions such as 0.1 cannot be stored exactly. To write correct code for money, IDs, counters or scientific values, you need to know where those limits are.
IEEE 754 double precision. Every number is stored in 64 bits, split into three fields:
1 bit 11 bits 52 bits
+------+------------+-----------------------------+
| sign | exponent | fraction |
+------+------------+-----------------------------+
value = (-1)^sign x 1.fraction (binary) x 2^(exponent - 1023)
The 52-bit fraction (plus one implicit bit) gives about 15 to 17 significant decimal digits. Consequences:
| Limit | Value | Meaning |
|---|---|---|
Number.MAX_SAFE_INTEGER | 2^53 - 1 = 9 007 199 254 740 991 | Largest integer n such that n and n + 1 are both exactly representable |
Number.MIN_SAFE_INTEGER | -(2^53 - 1) | Smallest such integer |
Number.MAX_VALUE | about 1.8e308 | Largest finite number. Beyond it you get Infinity. |
Number.MIN_VALUE | 5e-324 | Smallest positive number |
Number.EPSILON | 2^-52, about 2.2e-16 | Gap between 1 and the next representable number |
Above MAX_SAFE_INTEGER, not every integer can be stored, so 2 ** 53 + 1 === 2 ** 53 is true. This matters for 64-bit database IDs and Twitter-style snowflake IDs: send them as strings in JSON.
Why 0.1 + 0.2 !== 0.3. In base 10, 1/3 has no finite representation (0.333...). In base 2, 1/10 has none either, so 0.1 is stored as the closest double, which is very slightly off. The tiny errors in 0.1 and 0.2 add up to a result that is not the closest double to 0.3.
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
// Compare floats with a tolerance instead of ===
const nearlyEqual = (a, b, eps = Number.EPSILON) =>
Math.abs(a - b) <= eps * Math.max(1, Math.abs(a), Math.abs(b)); // relative tolerance
console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true
// Money: work in integer minor units (cents), format only for display
const priceCents = 1999; // 19.99
const totalCents = priceCents * 3; // 5997: exact integer arithmetic
console.log(new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR" })
.format(totalCents / 100)); // "€59.97"
Special values. Floating-point arithmetic never throws. Invalid or overflowing operations produce special values instead:
| Value | Produced by | Notes |
|---|---|---|
Infinity, -Infinity | 1 / 0, -1 / 0, overflow (1e308 * 10) | Number.isFinite(x) rejects them |
NaN ("not a number") | 0 / 0, Math.sqrt(-1), Number("abc") | Still typeof "number". NaN !== NaN. |
-0 | -1 / Infinity, Math.round(-0.4) | -0 === 0 is true. Object.is(-0, 0) is false. |
NaN is the only value not equal to itself. Detect it with Number.isNaN(x). The older global isNaN(x) converts its argument first, so isNaN("hello") is true, which is rarely what you want.
Writing numeric literals.
255; // decimal
0xff; // hexadecimal -> 255
0o377; // octal -> 255
0b1111_1111; // binary with a numeric separator -> 255
1_000_000; // separators (ES2021) are only for readability
2.5e-3; // exponent notation -> 0.0025
Parsing and converting.
| Expression | Result | Behaviour |
|---|---|---|
Number("42") / +"42" | 42 | Whole string must be numeric (whitespace is trimmed) |
Number("12px") | NaN | Strict |
Number("") | 0 | A common surprise |
parseInt("12px", 10) | 12 | Reads leading digits, stops at the first invalid character |
parseFloat("3.5kg") | 3.5 | Same, for decimals |
parseInt("0.0000005") | 0 | String is read as-is |
parseInt(0.0000005) | 5 | The number is first converted to the string "5e-7" |
Always pass the radix to parseInt, and only pass it strings.
Rounding and formatting. Math.round, Math.floor, Math.ceil and Math.trunc return numbers. toFixed(digits) returns a string and inherits binary representation errors: (1.005).toFixed(2) is "1.00" because 1.005 is actually stored as 1.00499999.... Math.round(-2.5) is -2, because it rounds halves towards positive infinity. For user-facing output, prefer Intl.NumberFormat, which handles locales, currencies and grouping.
Integer-only operations. Bitwise operators (|, &, ^, ~, <<, >>, >>>) convert their operands to 32-bit signed integers. x | 0 truncates, but silently breaks for values above 2^31 - 1. Use Math.trunc instead.
BigInt. When you need integers larger than 2^53 - 1 with exact arithmetic (cryptography, 64-bit IDs, large factorials), use bigint. Write a literal with an n suffix or convert with BigInt().
const big = 2n ** 64n; // 18446744073709551616n: exact
console.log(typeof big); // "bigint"
console.log(9007199254740993n); // exact, unlike 9007199254740993 (a number)
console.log(7n / 2n); // 3n: division truncates towards zero
console.log(-7n / 2n); // -3n
// 1n + 1; // TypeError: cannot mix BigInt and other types
console.log(1n + BigInt(1)); // 2n: convert explicitly
console.log(Number(2n ** 60n)); // converts back, possibly losing precision
console.log(1n == 1, 1n === 1, 2n > 1); // true false true: comparisons across types work
// JSON.stringify({ id: 1n }); // TypeError: BigInt is not serialisable by default
console.log(JSON.stringify({ id: String(1n) })); // '{"id":"1"}': serialise as a string
// BigInt(1.5); // RangeError: not an integer
number | bigint | |
|---|---|---|
| Range | About ±1.8e308, exact integers up to 2^53 - 1 | Unlimited (memory-bound) integers |
| Fractions | Yes (binary floating point) | No |
| Speed | Hardware-fast | Slower, grows with size |
Math.* functions | Yes | No |
| Mixing in arithmetic | Must convert explicitly; mixing throws TypeError | |
| JSON | Native | Throws unless you convert to a string |
Key Takeaways
numberis an IEEE 754 64-bit float. Integers are exact only up toNumber.MAX_SAFE_INTEGER(2^53 - 1).- Decimal fractions are approximate. Compare floats with a tolerance and store money as integer minor units.
NaNis not equal to itself. UseNumber.isNaNandNumber.isFinite, not the globalisNaNandisFinite.Number()is strict,parseInt/parseFloatread a numeric prefix. Always pass a radix toparseInt.bigintprovides exact, arbitrary-size integers but cannot be mixed withnumberwithout explicit conversion.
Practice
- (Beginner) Write
sumPrices(prices)that adds an array of prices such as[0.1, 0.2, 19.99]and returns a correctly rounded string with two decimals. Do it once with floats andtoFixed, and once with integer cents. Find an input for which the two versions disagree.- (Intermediate) Write
factorial(n)withbigintand computefactorial(25n). Then compute the same value withnumberand explain from the 52-bit fraction why the last digits differ.- (Interview) "An API returns
{ "id": 1234567890123456789 }and afterJSON.parseyour code fetches the wrong record. What happened, and how do you fix it?" Hint: consider what happens to integers above 2^53 during parsing, and where the fix belongs (server output format or a custom parser).
Strings and Template Literals
Text is everywhere in web development: URLs, HTML, user input, JSON, log messages. JavaScript strings look simple, but two details cause many bugs. Strings are immutable, so every "modification" creates a new string. And strings are sequences of UTF-16 code units, not characters, so lengths and indices can be surprising for text outside the Basic Multilingual Plane. Template literals, added in ES2015, made building strings much easier and enabled a powerful feature called tagged templates.
Three ways to write a string.
| Syntax | Interpolation | Multi-line | Typical use |
|---|---|---|---|
'single' | No | No | Most code (style-guide preference) |
"double" | No | No | Same as single quotes, no functional difference |
`backtick` | ${expression} | Yes | Building strings from values, multi-line text |
Escape sequences work in all three: \n (newline), \t (tab), \\ (backslash), \', \", \u00e9 (é), \u{1D11E} (any code point).
Immutability. You can read characters with s[0] or s.at(-1), but you cannot write them. s[0] = "X" is silently ignored (or throws in strict mode). Every method returns a new string.
Essential methods.
| Method | Example | Result |
|---|---|---|
at(i) | "hello".at(-1) | "o" (negative indices count from the end) |
slice(start, end) | "hello".slice(1, 3) | "el" (negative indices allowed) |
includes, startsWith, endsWith | "hello".startsWith("he") | true |
indexOf, lastIndexOf | "hello".indexOf("l") | 2 (-1 if not found) |
split(sep) | "a,b,c".split(",") | ["a", "b", "c"] |
trim, trimStart, trimEnd | " hi ".trim() | "hi" |
padStart, padEnd | "7".padStart(3, "0") | "007" |
replace, replaceAll | "a-b-c".replaceAll("-", "+") | "a+b+c" (replace with a string changes only the first match) |
toUpperCase, toLowerCase | "Straße".toUpperCase() | "STRASSE" (length can change) |
repeat(n) | "ab".repeat(3) | "ababab" |
localeCompare | "é".localeCompare("f", "fr") | -1 (language-aware ordering) |
normalize(form) | "e\u0301".normalize("NFC") | "é" as one code point |
UTF-16 and code units. Each string element is a 16-bit code unit. Characters with code points above U+FFFF (many CJK ideographs, musical and mathematical symbols, and emoji) need two code units, called a surrogate pair. length and index access count code units, while for...of, spread and Array.from iterate by code point.
const clef = "\u{1D11E}"; // MUSICAL SYMBOL G CLEF: one code point, two code units
console.log(clef.length); // 2
console.log(clef[0]); // "\uD834": half of a surrogate pair, not a real character
console.log([...clef].length); // 1: iteration is by code point
console.log(clef.codePointAt(0)); // 119070 (0x1D11E)
// Accents can be composed (1 code point) or decomposed (letter + combining mark)
const a = "caf\u00e9"; // "café"
const b = "cafe\u0301"; // also displays as "café"
console.log(a === b); // false
console.log(a.normalize() === b.normalize()); // true: normalise before comparing user input
// What a reader perceives as one character is a "grapheme cluster"
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
console.log([...seg.segment(b)].length); // 4
Comparing strings. < and > compare code unit by code unit, so all uppercase ASCII letters sort before lowercase ones ("Z" < "a" is true) and accented letters sort after z. For user-visible sorting use localeCompare or, for many comparisons, a reusable Intl.Collator.
Template literals. Backtick strings can embed any expression with ${...}, and can span lines. The expression is converted to a string with the normal ToString rules (see Type Coercion and Equality Rules).
const user = { name: "Ada", items: 3 };
const msg = `Hello ${user.name}, you have ${user.items} item${user.items === 1 ? "" : "s"}.`;
console.log(msg); // "Hello Ada, you have 3 items."
const html = `
<ul>
${["a", "b"].map((x) => `<li>${x}</li>`).join("")}
</ul>`; // newlines and indentation are kept literally
Tagged templates. If you put a function name directly before a template literal, the function is called with the literal's static string parts and the evaluated values separately. The tag can process the values (escape them, translate, build a query) before joining the parts.
tag`Hi ${name}, you owe ${amount}!`
tag(
["Hi ", ", you owe ", "!"], <- strings: always one more than the values
name, <- values, already evaluated
amount
)
// A tag that HTML-escapes every interpolated value, preventing injection (Chapter 6)
const escapeHtml = (v) =>
String(v).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
function safeHtml(strings, ...values) {
return strings.reduce(
(out, str, i) => out + str + (i < values.length ? escapeHtml(values[i]) : ""),
""
);
}
const comment = '<img src=x onerror="alert(1)">';
console.log(safeHtml`<p>${comment}</p>`);
// "<p><img src=x onerror="alert(1)"></p>"
console.log(String.raw`C:\new\table`); // "C:\new\table": built-in tag that skips escape processing
Tagged templates power libraries such as lit-html, styled-components and SQL query builders that turn values into bound parameters.
Key Takeaways
- Strings are immutable. Every method returns a new string.
lengthand indices count UTF-16 code units. Characters above U+FFFF use two. Iterate withfor...ofor spread to get code points, and useIntl.Segmenterfor user-perceived characters.- Normalise (
normalize()) before comparing text that may contain accents, and uselocaleCompareorIntl.Collatorfor sorting. - Template literals support
${}interpolation and multi-line text. - A tagged template passes static parts and values separately to a function, which enables safe escaping and DSLs.
Practice
- (Beginner) Write
capitalize(s)that upper-cases the first letter of each word in a sentence, usingsplit,map,atorslice, andjoin. Make sure it handles multiple spaces.- (Intermediate) Write a
reverse(s)that correctly reverses"ab\u{1D11E}c"and compare it withs.split("").reverse().join(""). Explain why the naive version produces broken output.- (Interview) "Implement a tagged template
sqlso thatsql`SELECT * FROM users WHERE id = ${id}`returns{ text: 'SELECT * FROM users WHERE id = $1', params: [id] }." Hint: the strings array has one more element than the values array; number the placeholders as you join them.
Booleans, null and undefined
Programs constantly need to represent "yes or no" and "there is nothing here". JavaScript has one type for the first (boolean) and, unusually, two separate values for the second: undefined and null. They behave differently in default parameters, JSON, arithmetic and equality, so knowing which one appears where, and which one to use yourself, prevents a whole class of bugs.
Booleans. The boolean type has exactly two values, true and false. Comparisons and the ! operator produce booleans. Any value can be converted with Boolean(value) or the double negation !!value, following the truthy and falsy rules in Truthy and Falsy Values.
undefined: "not set (yet)". The language itself produces undefined whenever a value is missing:
| Situation | Example | Value |
|---|---|---|
| Declared variable without initial value | let x; | undefined |
| Missing function argument | function f(a) {} called as f() | a is undefined |
| Missing object property | ({}).name | undefined |
| Array hole or out-of-range index | [1, 2][5] | undefined |
Function without return | (function () {})() | undefined |
void operator | void 0 | undefined |
null: "deliberately empty". The language almost never produces null on its own. It appears when a program or an API deliberately states that there is no value: document.querySelector(".missing") returns null, a prototype chain ends in null, and JSON uses null for empty fields. A useful convention: undefined means "absent", null means "present but empty".
How the two differ.
| Behaviour | undefined | null |
|---|---|---|
typeof | "undefined" | "object" (historical bug) |
Number(x) | NaN | 0 |
x + 1 | NaN | 1 |
| Triggers a default parameter | Yes | No |
| Triggers a destructuring default | Yes | No |
JSON.stringify({ a: x }) | "{}" (the key is dropped) | '{"a":null}' |
x == null | true | true |
x === null | false | true |
x ?? "fallback" (3.2) | "fallback" | "fallback" |
Example: the differences in practice
function greet(name = "guest") { // default applies only when name is undefined
return `Hello, ${name}`;
}
console.log(greet()); // "Hello, guest"
console.log(greet(undefined)); // "Hello, guest"
console.log(greet(null)); // "Hello, null": null is a real value
console.log(JSON.stringify({ a: undefined, b: null })); // '{"b":null}'
// "Missing" vs "present but undefined"
const config = { debug: undefined };
console.log(config.debug === undefined); // true
console.log(config.verbose === undefined); // true: cannot tell the two apart this way
console.log("debug" in config); // true: the key exists
console.log(Object.hasOwn(config, "verbose")); // false: the key does not exist
// One check for both "empty" values
function isMissing(v) {
return v == null; // true for null and undefined only (see Equality)
}
console.log(isMissing(0), isMissing(""), isMissing(null)); // false false true
Why typeof null is "object". In the first JavaScript implementation, values carried a small type tag, and the tag for objects was 0. null was represented as the null pointer, whose bits were all zero, so typeof read it as an object. Fixing it would break existing websites, so the bug became part of the specification.
undefined is not a keyword. undefined is a read-only property of the global object, so you cannot overwrite it globally in modern engines. You can still declare a local variable named undefined, which is why older code writes void 0 to get a guaranteed undefined. Linters forbid shadowing it, and you will rarely need void 0 today.
Choosing between them in your own code.
- Let the language produce
undefined. Do not assignundefinedexplicitly unless you are deliberately "unsetting" something. - Use
nullwhen an API or data structure needs to say "this field exists and is empty", for example in JSON payloads and database records. - When consuming values, treat both as "no value" with
== nullor??, unless the distinction matters.
Key Takeaways
booleanhas two values. Convert other values withBoolean(x)or!!x.undefinedis what the language gives you for missing values.nullis an intentional "empty" set by code.- Default parameters and destructuring defaults trigger only for
undefined, never fornull. JSON.stringifydropsundefinedproperties but keepsnullones.Number(null)is0butNumber(undefined)isNaN.x == nullis the idiomatic check for "null or undefined". UseinorObject.hasOwnto check whether a key exists.
Practice
- (Beginner) For each expression, predict the result:
null + 1,undefined + 1,null == 0,null == undefined,null === undefined,typeof undefined,typeof undeclaredVariable.- (Intermediate) A PATCH endpoint receives JSON. A field set to
nullmust clear the database value, and a field that is absent must be left unchanged. WriteapplyPatch(record, patch)that implements this correctly, and explain whyif (patch.email)is the wrong test.- (Interview) "What is the difference between
nullandundefined, and when would you use each one?" Hint: talk about who produces each value, defaults, JSON and the== nullidiom.
Symbols
As programs grow and combine code from many libraries, property names start to collide. If two libraries both add a property called id or meta to the same object, one silently overwrites the other. ES2015 introduced symbols to solve this: a symbol is a primitive value that is guaranteed to be unique, so a property keyed by a symbol can never clash with any other key. Symbols also give the language a way to define protocols, hooks such as "how to iterate this object", without reserving ordinary property names.
Creating symbols. Call Symbol() with an optional description. The description is only a debugging label. Two symbols with the same description are still different.
const id1 = Symbol("id");
const id2 = Symbol("id");
console.log(id1 === id2); // false: every call creates a new unique value
console.log(id1.description); // "id"
console.log(typeof id1); // "symbol"
// new Symbol("id"); // TypeError: Symbol is not a constructor
Symbols as property keys. Use bracket notation, or a computed key in an object literal.
const META = Symbol("meta");
const user = {
name: "Ada",
[META]: { fetchedAt: Date.now() }, // computed key: the symbol, not the string "META"
};
console.log(user[META].fetchedAt); // works with the original symbol reference
console.log(Object.keys(user)); // ["name"]: symbol keys are skipped
console.log(JSON.stringify(user)); // '{"name":"Ada"}': skipped here too
for (const k in user) console.log(k); // "name" only
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(meta)]
console.log(Reflect.ownKeys(user)); // ["name", Symbol(meta)]
| Operation | Includes string keys | Includes symbol keys |
|---|---|---|
for...in, Object.keys, Object.entries | Yes | No |
JSON.stringify | Yes | No |
Object.getOwnPropertySymbols | No | Yes |
Reflect.ownKeys | Yes | Yes |
Object.assign, object spread {...obj} | Yes | Yes |
Symbol keys are hidden, not private. Anyone with access to the object can list them with Reflect.ownKeys. For true privacy, use closures (3.3) or private class fields (Chapter 4).
The global symbol registry. Sometimes separate pieces of code (different modules, or a page and an iframe) need to agree on the same symbol. Symbol.for(key) looks the key up in a runtime-wide registry and creates the symbol only the first time.
const a = Symbol.for("app.logger");
const b = Symbol.for("app.logger"); // returns the existing symbol
console.log(a === b); // true
console.log(Symbol.keyFor(a)); // "app.logger"
console.log(Symbol.keyFor(Symbol("x"))); // undefined: not a registered symbol
Use a namespaced key ("mylib.feature"), because the registry is shared by all code in the realm.
Well-known symbols: language protocols. The language defines built-in symbols, available as static properties of Symbol, that let your objects plug into core behaviour:
| Symbol | Lets an object define... | Used by |
|---|---|---|
Symbol.iterator | How to iterate it (see Generators and Iterators) | for...of, spread, destructuring, Array.from |
Symbol.asyncIterator | Asynchronous iteration | for await...of (Chapter 5) |
Symbol.toPrimitive | Conversion to a primitive (see Type Coercion and Equality Rules) | +obj, `${obj}`, obj == 1 |
Symbol.toStringTag | The label in Object.prototype.toString | "[object MyType]" |
Symbol.hasInstance | Custom instanceof behaviour | x instanceof C |
Example: making an object iterable and convertible
const temperature = {
celsius: 21.5,
[Symbol.toPrimitive](hint) { // hint: "number", "string" or "default"
return hint === "string" ? `${this.celsius} °C` : this.celsius;
},
get [Symbol.toStringTag]() { return "Temperature"; },
};
console.log(+temperature); // 21.5 (hint "number")
console.log(`${temperature}`); // "21.5 °C" (hint "string")
console.log(Object.prototype.toString.call(temperature)); // "[object Temperature]"
const week = {
days: ["Mon", "Tue", "Wed"],
[Symbol.iterator]() { // the iteration protocol: return an object with next()
let i = 0;
return { next: () => ({ value: this.days[i], done: i++ >= this.days.length }) };
},
};
console.log([...week]); // ["Mon", "Tue", "Wed"]
Converting symbols. Symbols refuse implicit conversion to strings or numbers, to stop them from accidentally turning into ordinary property names. Use String(sym) or sym.description explicitly.
const s = Symbol("token");
console.log(String(s)); // "Symbol(token)"
// s + ""; // TypeError: Cannot convert a Symbol value to a string
// `${s}`; // TypeError as well
Key Takeaways
Symbol(description)creates a unique primitive. The description is only a label.- Symbol-keyed properties never collide with other keys and are skipped by
for...in,Object.keysandJSON.stringify, but they are not private. Symbol.for(key)returns a shared symbol from a global registry, for coordination across modules or realms.- Well-known symbols such as
Symbol.iteratorandSymbol.toPrimitivelet your objects customise built-in language behaviour. - Symbols throw on implicit string conversion. Convert them explicitly with
String()or.description.
Practice
- (Beginner) Create an object with two string keys and one symbol key. Log the results of
Object.keys,Object.getOwnPropertySymbols,Reflect.ownKeysandJSON.stringifyand explain each one.- (Intermediate) Write a
Moneyobject withamountandcurrencywhoseSymbol.toPrimitivereturns the amount for numeric hints and a formatted string such as"12.50 EUR"for string hints. Test it with+m,`${m}`andm > 10.- (Interview) "Why did JavaScript add symbols instead of just reserving a property name like
__iterator__?" Hint: think about existing code on the web that might already use that name, and about whatObject.keysorJSON.stringifywould expose.
var, let and const
A variable is a name bound to a value. Until 2015, JavaScript had only var for declaring variables, and var has rules that surprise programmers coming from almost any other language: it ignores blocks, it can be redeclared, and it can be used before the line that declares it. ES2015 added let and const, which behave the way most people expect. Modern code uses const and let, but you will read var in older code and tutorials, so you need to understand all three.
Comparison.
var | let | const | |
|---|---|---|---|
| Scope | Function (or global) | Block { } | Block { } |
| Hoisted | Yes, initialised to undefined | Yes, but uninitialised (TDZ) | Yes, but uninitialised (TDZ) |
| Use before declaration | Gives undefined | ReferenceError | ReferenceError |
| Redeclare in same scope | Allowed | SyntaxError | SyntaxError |
| Reassign | Yes | Yes | No (TypeError) |
| Must be initialised | No | No | Yes |
Top-level declaration in a classic script creates a window property | Yes | No | No |
| New binding per loop iteration | No | Yes | Yes (for for...of and for...in) |
Hoisting and the TDZ are covered in the next topic.
Function scope versus block scope. A var declared inside an if or for block belongs to the whole enclosing function. let and const belong only to the block in which they are declared.
function demo() {
if (true) {
var a = 1; // function-scoped: visible in the whole function
let b = 2; // block-scoped: visible only inside this if-block
const c = 3; // block-scoped
}
console.log(a); // 1
// console.log(b); // ReferenceError: b is not defined
}
demo();
const means a constant binding, not a constant value. You cannot reassign a const variable, but if it holds an object you can still change the object's contents. To make an object itself read-only, use Object.freeze (Chapter 4).
const settings = { theme: "dark" };
settings.theme = "light"; // allowed: the object is mutated, the binding is unchanged
// settings = {}; // TypeError: Assignment to constant variable.
const frozen = Object.freeze({ theme: "dark" });
frozen.theme = "light"; // ignored (TypeError in strict mode): the object is frozen
The classic loop bug. Because var creates a single variable for the whole function, every callback created in the loop shares it. By the time the callbacks run, the loop has finished. let creates a fresh binding for each iteration, so each callback captures its own value.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var", i), 0); // logs 3, 3, 3: one shared i
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let", j), 0); // logs 0, 1, 2: one j per iteration
}
var: one binding i ----------------------------> 3 when callbacks run
callback#1 --+
callback#2 --+--> all read the same i
callback#3 --+
let: iteration 0: j=0 <-- callback#1
iteration 1: j=1 <-- callback#2
iteration 2: j=2 <-- callback#3
Globals. At the top level of a classic <script>, var x and function declarations become properties of the global object (window.x), where any other script can overwrite them. Top-level let and const are global variables too, but they are not added to window. In ES modules (3.4), top-level declarations of any kind are local to the module. Assigning to an undeclared name (total = 5 without a declaration) creates a global property in sloppy mode and throws a ReferenceError in strict mode.
Recommended practice.
- Use
constby default. Most variables are never reassigned, andconstmakes that explicit. - Use
letwhen the variable really needs to be reassigned (counters, accumulators, state that changes). - Do not use
varin new code. Linters (no-var,prefer-const) enforce this automatically.
Key Takeaways
varis function-scoped, redeclarable and initialised toundefinedwhen hoisted.letandconstare block-scoped and cannot be used before their declaration.constprevents reassignment of the binding, not mutation of the object it refers to.letin a loop header creates a new binding per iteration, which fixes the classic closure-in-a-loop bug.- Top-level
varin a classic script polluteswindow. Top-levelletandconstdo not. - Default to
const, useletwhen you must reassign, and avoidvar.
Practice
- (Beginner) Rewrite a 20-line snippet that uses only
varso that it usesconstwherever possible andletelsewhere. Run ESLint withprefer-constto confirm your choices.- (Intermediate) Fix the
varloop above in three different ways without changingvartolet: with an IIFE, withforEach, and with the third argument ofsetTimeout. Explain why each one works.- (Interview) "If
constmeans constant, why can I push items into aconstarray?" Hint: distinguish between the variable binding and the object the binding refers to.
Scope, Hoisting and the Temporal Dead Zone
When your code uses a name such as total, the engine needs to find out which variable that name refers to. Scope is the set of rules that answers this question. Hoisting describes when declarations come into existence, and the temporal dead zone (TDZ) describes the period in which a let, const or class exists but cannot yet be used. Together they explain error messages such as "Cannot access 'x' before initialization" and why some functions can be called before they appear in the file.
Lexical scope. JavaScript uses lexical (static) scoping: the scope of a variable is determined by where it is written in the source code, not by where or how a function is called. Scopes nest like boxes:
+-- global / module scope -------------------------------------+
| const appName = "Shop"; |
| |
| +-- function checkout(cart) ------------------------------+ |
| | const tax = 0.2; | |
| | | |
| | +-- block: for (const item of cart) { ... } ---------+ | |
| | | const line = item.price * (1 + tax); | | |
| | | console.log(appName, line); // sees tax, appName | | |
| | +-----------------------------------------------------+ | |
| +----------------------------------------------------------+ |
+---------------------------------------------------------------+
Lookup goes from the inside out: block -> function -> module/global.
Inner scopes see outer variables. Outer scopes never see inner ones.
| Scope type | Created by | Contains |
|---|---|---|
| Global | The page or runtime | Built-ins, top-level declarations of classic scripts |
| Module | Each ES module file | Top-level declarations of that module |
| Function | Every function call | Parameters, var, let, const, inner functions |
| Block | { } in if, for, while, try, bare blocks | let, const, class (and functions in strict mode) |
If no scope in the chain defines the name, the result is a ReferenceError: x is not defined. When an inner scope declares a variable with the same name as an outer one, it shadows the outer variable within the inner scope.
Hoisting: the creation phase. Before running the code in a scope, the engine scans it and registers every declaration. That is why declarations behave as if they had been "hoisted" (moved) to the top of their scope. They are not literally moved; they are created before execution starts. What differs is how they are initialised:
| Declaration | Created at scope entry | Initial value | Access before the declaration line |
|---|---|---|---|
function f() {} | Yes | The complete function | Works: the function can be called |
var x | Yes | undefined | Gives undefined |
let x, const x | Yes | Uninitialised | ReferenceError (TDZ) |
class C {} | Yes | Uninitialised | ReferenceError (TDZ) |
import | Yes (module linking) | Bound to the exporting module | Works (live binding) |
console.log(sum(2, 3)); // 5: function declarations are hoisted complete
console.log(count); // undefined: var is hoisted and initialised to undefined
// console.log(limit); // ReferenceError: Cannot access 'limit' before initialization
function sum(a, b) { return a + b; }
var count = 10;
let limit = 5;
The temporal dead zone. For let, const and class, the TDZ is the period between entering the scope and executing the declaration. The name already exists (it shadows any outer variable with the same name), but any access throws. The zone is temporal: it depends on when code runs, not on where it is written.
let x = "outer";
{
// TDZ for the inner x starts here
// console.log(x); // ReferenceError: the inner x already shadows the outer one
let x = "inner"; // TDZ ends
console.log(x); // "inner"
}
function later() {
return value; // fine to *write* this before the declaration...
}
const value = 42;
console.log(later()); // 42: ...because it is *called* after initialisation
// typeof is not safe inside the TDZ
// typeof y; // ReferenceError
let y;
console.log(typeof notDeclaredAnywhere); // "undefined": undeclared names are fine for typeof
// Parameters have their own TDZ, evaluated left to right
function f(a = b, b = 1) {}
// f(); // ReferenceError: Cannot access 'b' before initialization
Why have a TDZ at all, when var simply gives undefined? Reading a variable before it is initialised is almost always a bug, and for const there would be no sensible value to return before initialisation. The TDZ turns that silent bug into an immediate error.
Key Takeaways
- Scope is lexical: it is decided by where code is written. Lookup goes outward from the innermost scope.
- There are global, module, function and block scopes.
let,constandclassare block-scoped. - All declarations are hoisted (created when the scope is entered), but only function declarations are fully initialised and
varstarts asundefined. let,constandclassare in the temporal dead zone until their declaration runs. Accessing them earlier throws aReferenceError, even withtypeof.- The TDZ is about time, not position: a function can refer to a later
constas long as it is called after theconstis initialised.
Practice
- (Beginner) Write down what this logs, then run it:
var a = 1; function f() { console.log(a); var a = 2; } f();. Explain the result in terms of hoisting.- (Intermediate) Draw the scope chain for a module that contains a function with a nested
forloop and an inner arrow function. For each variable, mark which scopes can see it and identify one case of shadowing.- (Interview) "Are
letandconsthoisted?" Hint: the answer is yes, but explain what is different fromvarand give an example in which an innerletcauses aReferenceErroreven though an outer variable with the same name exists.
Type Coercion and Equality Rules
JavaScript was designed to keep running even when given "wrong" types. Ask it to subtract a string from a number, and instead of raising an error it converts the string to a number. This automatic conversion, called implicit coercion, explains both the convenience and the most famous puzzles of the language ([] + {}, "5" - 2 versus "5" + 2). You do not need to memorise every rule, but you do need to know the three basic conversions, how + decides between adding and concatenating, and how == differs from ===.
Explicit and implicit coercion. Explicit coercion is when you ask for the conversion: Number(x), String(x), Boolean(x). Implicit coercion is when an operator does it for you: x - 1, `${x}`, if (x). Both follow the same internal rules.
Abstract operations. The specification defines a few internal conversion operations that every operator uses:
| Operation | Converts to | Triggered by |
|---|---|---|
| ToPrimitive | A primitive | Any operator applied to an object |
| ToNumber | number | -, *, /, %, unary +, <, >, == with numbers |
| ToString | string | + with a string, template literals, String(), property keys |
| ToBoolean | boolean | if, while, !, &&, ` |
ToNumber and ToString tables.
| Value | ToNumber | ToString |
|---|---|---|
"" | 0 | "" |
" 42 " | 42 | " 42 " |
"42px" | NaN | "42px" |
true / false | 1 / 0 | "true" / "false" |
null | 0 | "null" |
undefined | NaN | "undefined" |
[] | 0 (via "") | "" |
[7] | 7 (via "7") | "7" |
[1, 2] | NaN (via "1,2") | "1,2" |
{} | NaN (via "[object Object]") | "[object Object]" |
-0 | -0 | "0" |
Symbol() | TypeError | TypeError (implicit) |
ToPrimitive: how objects become primitives. When an operator needs a primitive and receives an object, the engine:
1. If obj[Symbol.toPrimitive] exists, call it with a hint ("number", "string" or "default").
2. Otherwise, with hint "string": try obj.toString(), then obj.valueOf()
with hint "number"/"default": try obj.valueOf(), then obj.toString()
The first one that returns a primitive wins. If neither does: TypeError.
For plain objects and arrays, valueOf() returns the object itself (not a primitive), so toString() is used. That is why arrays become comma-separated strings and plain objects become "[object Object]". Date objects are the exception: with the "default" hint they prefer strings.
The + operator. + both adds numbers and concatenates strings, so it needs a rule to choose:
a + b
1. Convert both operands with ToPrimitive (hint "default").
2. If either result is a string -> convert both to strings and concatenate.
3. Otherwise -> convert both to numbers and add.
All other arithmetic operators (-, *, /, %, **) always convert to numbers.
console.log("5" + 2); // "52": a string is present, so concatenation
console.log("5" - 2); // 3: - always converts to numbers
console.log("5" * "2"); // 10
console.log(1 + true); // 2: true -> 1
console.log(1 + null); // 1: null -> 0
console.log(1 + undefined); // NaN
console.log([] + []); // "": both arrays become ""
console.log([] + {}); // "[object Object]"
console.log([1, 2] + [3]); // "1,23"
console.log(1 + 2 + "3"); // "33": evaluated left to right, (1 + 2) + "3"
console.log("1" + 2 + 3); // "123"
Relational comparison (<, >, <=, >=). After ToPrimitive, if both operands are strings, they are compared code unit by code unit. Otherwise both are converted to numbers.
console.log("10" < "9"); // true: string comparison, "1" comes before "9"
console.log(10 < "9"); // false: numeric comparison, 10 < 9
console.log("a" < "b"); // true
console.log(null >= 0); // true: null -> 0 for relational operators...
console.log(null == 0); // false: ...but == has its own rule for null (see below)
Strict equality (===). Returns true only if both operands have the same type and the same value. There is no coercion. Two exceptions come from floating point: NaN === NaN is false, and +0 === -0 is true. Objects are equal only if they are the same object (same reference).
Loose equality (==). If the types differ, == converts operands following this algorithm:
x == y
same type? -> compare like ===
null == undefined (either order) -> true (and null/undefined equal nothing else)
number == string -> ToNumber(string), compare again
boolean == anything -> ToNumber(boolean), compare again
object == primitive -> ToPrimitive(object), compare again
bigint == number/string -> compare mathematical values
otherwise -> false
The boolean rule causes most surprises: booleans become numbers before anything else, so x == true does not mean "x is truthy".
| Expression | Result | Why |
|---|---|---|
"" == 0 | true | "" becomes 0 |
"0" == 0 | true | "0" becomes 0 |
"" == "0" | false | Same type: different strings |
"0" == false | true | false becomes 0, then "0" becomes 0 |
"2" == true | false | true becomes 1, and 2 != 1, although "2" is truthy |
[] == false | true | false becomes 0, [] becomes "" then 0 |
[] == ![] | true | ![] is false, then as above |
null == 0 | false | null equals only null and undefined |
null == undefined | true | Special rule |
NaN == NaN | false | NaN equals nothing |
[1, 2] == "1,2" | true | The array becomes "1,2" |
{} == {} | false | Two different objects |
Three equality algorithms.
| Algorithm | Used by | NaN vs NaN | +0 vs -0 | Coerces types |
|---|---|---|---|---|
Loose (==) | ==, != | false | true | Yes |
Strict (===) | ===, !==, indexOf, switch | false | true | No |
| SameValueZero | includes, Map, Set | true | true | No |
| SameValue | Object.is | true | false | No |
console.log([NaN].indexOf(NaN)); // -1: indexOf uses ===
console.log([NaN].includes(NaN)); // true: includes uses SameValueZero
console.log(Object.is(-0, 0)); // false
Recommended practice. Use === and !== everywhere. Convert explicitly (Number(input)) when you need a different type. The one common exception is x == null, which concisely checks for both null and undefined, and which most style guides (including ESLint's eqeqeq with the "null": "ignore" option) allow.
Key Takeaways
- Operators coerce through a few internal operations: ToPrimitive, ToNumber, ToString and ToBoolean.
+concatenates if either operand (after ToPrimitive) is a string. All other arithmetic operators convert to numbers.- Relational operators compare strings lexically only when both operands are strings.
==converts booleans to numbers first, treatsnullandundefinedas equal only to each other, and converts objects to primitives.===never coerces.- Use
===by default,== nullfor "null or undefined", andObject.isorincludeswhenNaNor-0matter.
Practice
- (Beginner) Predict, then verify:
"3" * "4","3" + 4 + 5,3 + 4 + "5",true + true,[] + 1,[2] * [3],"b" + "a" + +"a" + "a".- (Intermediate) Create an object
counterwhoseSymbol.toPrimitivereturns an incrementing number each time it is converted, so thatcounter == 1 && counter == 2 && counter == 3istrue. Then explain why the same trick cannot makecounter === 1true.- (Interview) "Walk me through how JavaScript evaluates
[] == ![]." Hint: evaluate![]first using ToBoolean, then apply the==steps one at a time.
Truthy and Falsy Values
Conditions in JavaScript do not require booleans. You can write if (user), while (queue.length) or name || "Anonymous" with any value, and the language decides whether that value counts as true or false. That decision is the ToBoolean conversion. Its rule is short, but a few entries in it (0, "", NaN) cause real bugs when they are valid data rather than "missing".
The falsy list. A value is falsy if ToBoolean converts it to false. There are exactly eight falsy values (nine with one legacy oddity). Every other value is truthy.
| Falsy value | Type |
|---|---|
false | boolean |
0, -0 | number |
0n | bigint |
"" (empty string) | string |
null | null |
undefined | undefined |
NaN | number |
document.all | Legacy browser object, deliberately falsy for old feature detection |
Values that are truthy but look falsy.
| Truthy value | Why it surprises people |
|---|---|
"0", "false", " " | Non-empty strings are always truthy |
[] | Every object is truthy, including empty arrays |
{} | Also an object |
new Boolean(false) | A wrapper object, so truthy (see Primitive Types and Wrapper Objects) |
-1, Infinity | Only 0, -0 and NaN are falsy numbers |
function () {} | Functions are objects |
Where ToBoolean applies. if, else if, while, do...while, for conditions, the ternary ? :, !, Boolean(x), and the left operand of && and || (3.2). Note that == does not use ToBoolean. if ("2") runs, but "2" == true is false (see Type Coercion and Equality Rules).
Example: idioms and the bugs they hide
// Concise existence checks: fine when falsy values can never be valid data
const el = document.querySelector("#menu");
if (el) el.classList.add("open"); // el is an object or null
// Bug: 0 is valid data but falsy
function setVolume(level) {
const volume = level || 50; // setVolume(0) gives 50!
return volume;
}
console.log(setVolume(0)); // 50: wrong
function setVolumeFixed(level) {
const volume = level ?? 50; // only null/undefined fall back (3.2)
return volume;
}
console.log(setVolumeFixed(0)); // 0: correct
// Bug: an empty array is truthy
const results = [];
if (results) console.log("always runs"); // [] is truthy
if (results.length) console.log("never runs"); // 0 is falsy: correct check
// Empty object check
const filters = {};
console.log(Object.keys(filters).length === 0); // true
// Filtering falsy entries from a list
console.log(["a", "", null, "b", undefined, 0].filter(Boolean)); // ["a", "b"]
// Converting to a real boolean, e.g. for an API that expects one
const hasDiscount = !!user?.coupon; // true or false, never the coupon object
Choosing the right check.
| You want to know whether... | Write | Not |
|---|---|---|
A value is present (not null/undefined) | x != null or x ?? fallback | if (x) when 0 or "" is valid |
| A string is non-empty | str.length > 0 or str !== "" | if (str) when str might be a non-string |
| An array has items | arr.length > 0 | if (arr) |
| A number is a usable value | Number.isFinite(n) | if (n) |
A value is exactly true | x === true | x == true |
Key Takeaways
- The falsy values are
false,0,-0,0n,"",null,undefinedandNaN. Everything else is truthy. - All objects are truthy, including
[],{}andnew Boolean(false). Non-empty strings like"0"and"false"are truthy too. - Conditions,
!,&&,||and the ternary use ToBoolean.==does not. - Truthiness checks are fine for objects that may be
null. For numbers and strings where0or""are valid, use??or explicit checks. - Check arrays with
.lengthand objects withObject.keys(o).length.
Practice
- (Beginner) Write a table with ten values of your choice and classify each as truthy or falsy. Verify with
Boolean(value).- (Intermediate) Find and fix the bug:
function paginate(items, page) { page = page || 1; ... }should allow page0for a zero-based API, andfunction label(n) { return n ? n + " items" : "none"; }should print"0 items".- (Interview) "Why is
if ([])true but[] == falsealso true?" Hint: one uses ToBoolean, the other uses the loose-equality algorithm, which converts both sides to numbers.
<a id="32-expressions-operators-and-control-flow"></a>
3.2 Expressions, Operators and Control Flow
This subchapter covers how JavaScript computes values with operators, how it decides which code to run with conditionals and loops, and the modern syntax for unpacking and spreading data.
Arithmetic, Logical and Comparison Operators
A program is built from expressions (pieces of code that produce a value, such as price * qty or isAdmin && canEdit) and statements (pieces of code that perform an action, such as if, for or a variable declaration). Operators are the building blocks of expressions. Most of them look familiar from other C-style languages, but several details are specific to JavaScript: % is a remainder rather than a modulo, ** binds to the right, logical operators return operands rather than booleans, and some operator combinations are syntax errors without parentheses.
Arithmetic operators.
| Operator | Meaning | Example | Result |
|---|---|---|---|
+ | Addition or concatenation | 2 + 3 / "2" + 3 | 5 / "23" |
- | Subtraction | 7 - 10 | -3 |
* | Multiplication | 4 * 2.5 | 10 |
/ | Division (always floating point) | 7 / 2 | 3.5 |
% | Remainder (sign follows the dividend) | -7 % 3 | -1 |
** | Exponentiation (ES2016) | 2 ** 10 | 1024 |
++ / -- | Increment and decrement | i++, ++i | See below |
unary + / - | Convert to number / negate | +"42" | 42 |
Division by zero does not throw: 1 / 0 is Infinity and 0 / 0 is NaN (see Numbers, BigInt and Numeric Precision).
// % is a remainder: the result has the sign of the left operand
console.log(-7 % 3); // -1, not 2
const mod = (n, m) => ((n % m) + m) % m; // true modulo, always in [0, m)
console.log(mod(-7, 3)); // 2: useful for wrapping carousel indices
// ** is right-associative
console.log(2 ** 3 ** 2); // 512, i.e. 2 ** (3 ** 2)
// console.log(-2 ** 2); // SyntaxError: ambiguous, write (-2) ** 2
// Prefix vs postfix increment
let i = 5;
console.log(i++); // 5: returns the old value, then increments
console.log(++i); // 7: increments, then returns the new value
Assignment operators. Every arithmetic and bitwise operator has a compound form: x += 2 means x = x + 2, and so on (-=, *=, /=, %=, **=, <<=, |=, ...). The logical assignment operators ||=, &&= and ??= are covered in the next topic. Assignment is itself an expression whose value is the assigned value, which is why a = b = 0 works (it is right-associative).
Comparison operators.
| Operator | Meaning | Notes |
|---|---|---|
===, !== | Strict equality and inequality | No coercion (see Type Coercion and Equality Rules) |
==, != | Loose equality and inequality | Coerces types |
<, >, <=, >= | Relational | Compares strings lexically only if both operands are strings |
Any comparison involving NaN returns false, including NaN <= NaN. Note that a <= b is not defined as a < b || a == b: it is defined as !(b < a) with special handling for NaN, which is why null >= 0 is true while null == 0 is false.
Logical operators. && (and), || (or) and ! (not). ! always returns a boolean. && and || return one of their operands, not necessarily a boolean, and they short-circuit. The next topic covers them in detail.
Bitwise operators. &, |, ^, ~, <<, >> (sign-propagating) and >>> (zero-fill) work on 32-bit signed integers. They are mainly used for flag sets, binary protocols, hashing and graphics.
const READ = 0b001, WRITE = 0b010, EXEC = 0b100; // permission flags
let perms = READ | WRITE; // combine: 0b011
console.log((perms & WRITE) !== 0); // true: test a flag
perms &= ~WRITE; // clear a flag: 0b001
console.log(perms.toString(2)); // "1"
Other operators.
| Operator | Purpose | Example |
|---|---|---|
typeof x | Type name as a string | typeof 1 gives "number" |
x instanceof C | Prototype chain check (Chapter 4) | [] instanceof Array |
key in obj | Property existence, including inherited | "length" in [] gives true |
delete obj.key | Remove a property | Returns true on success |
void expr | Evaluate, return undefined | void 0 |
cond ? a : b | Conditional (ternary) | age >= 18 ? "adult" : "minor" |
a, b | Comma: evaluate both, return b | Mostly in for headers |
new C() | Create an instance (Chapter 4) | new Date() |
Precedence and associativity. When an expression contains several operators, precedence decides which binds tighter, and associativity decides the order for operators of equal precedence. An abridged table, from highest to lowest:
| Precedence | Operators | Associativity |
|---|---|---|
| Highest | ( ) grouping, ., ?., [ ], f(), new C() | Left |
Postfix x++, x-- | ||
!, ~, unary +/-, prefix ++/--, typeof, void, delete, await | Right | |
** | Right | |
*, /, % | Left | |
+, - | Left | |
<<, >>, >>> | Left | |
<, <=, >, >=, in, instanceof | Left | |
==, !=, ===, !== | Left | |
&, then ^, then ` | ` | |
&& | Left | |
| ` | ||
? : | Right | |
=, +=, &&=, ??=, ... , =>, yield | Right | |
| Lowest | , | Left |
console.log(1 + 2 * 3); // 7: * binds tighter than +
console.log((1 + 2) * 3); // 9
console.log(true || false && false); // true: && binds tighter, so true || (false && false)
console.log(typeof 1 + 1); // "number1": typeof binds tighter than +
// a ?? b || c; // SyntaxError: ?? cannot be mixed with || or && without parentheses
When in doubt, add parentheses. They cost nothing and make intent explicit for the next reader.
Key Takeaways
- Expressions produce values; statements perform actions. Operators build expressions.
/always produces a float,%is a remainder whose sign follows the dividend, and**is right-associative.x++returns the old value,++xthe new one. Assignment is an expression that returns the assigned value.&&and||return one of their operands, not necessarily a boolean. Bitwise operators work on 32-bit integers.- Precedence decides grouping. Use parentheses whenever it is not obvious, and they are mandatory when mixing
??with&&or||.
Practice
- (Beginner) Write a function that converts a number of seconds into
"hh:mm:ss"using only/,%,Math.floorandpadStart.- (Intermediate) Implement a permissions system with bit flags for
READ,WRITE,DELETEandADMIN, with functionsgrant,revoke,hasandlist. What is the maximum number of flags you can store this way, and why?- (Interview) "What do
typeof typeof 1and[] + null + 1evaluate to?" Hint: evaluate from the inside out for the first one, and left to right with the+concatenation rule for the second.
Short-Circuit, Nullish Coalescing and Optional Chaining
Real data is full of gaps: optional configuration options, API responses with missing fields, DOM queries that find nothing. Code that handles these gaps used to be full of nested if statements and obj && obj.a && obj.a.b chains. JavaScript now has a compact set of operators for this job. To use them correctly, you need to understand short-circuit evaluation and the difference between "falsy" and "nullish".
Short-circuit evaluation. && and || evaluate the left operand first and only evaluate the right one if it can still change the result. They return the value of whichever operand decided the result:
a || b -> a is truthy? return a (b is never evaluated)
otherwise return b
a && b -> a is falsy? return a (b is never evaluated)
otherwise return b
console.log("Ada" || "guest"); // "Ada"
console.log("" || "guest"); // "guest"
console.log(0 || null || "x"); // "x": returns the first truthy operand
console.log(1 && "yes"); // "yes": returns the last operand if all are truthy
console.log(null && "yes"); // null: returns the first falsy operand
// The right side is not evaluated at all
let calls = 0;
const count = () => ++calls;
false && count();
true || count();
console.log(calls); // 0
Because evaluation stops early, && is sometimes used as a compact "if": isLoggedIn && showDashboard(). It works, but a plain if statement is usually clearer for statements with side effects. In JSX (Chapter 14), {items.length && <List />} renders 0 when the list is empty, which is a well-known bug: use items.length > 0 && instead.
Nullish coalescing (??). || falls back for every falsy value, so it wrongly replaces valid values such as 0, "" and false. The nullish coalescing operator ?? (ES2020) falls back only when the left side is null or undefined (the "nullish" values).
| Left value | left || "default" | left ?? "default" |
|---|---|---|
| undefined | "default" | "default" |
| null | "default" | "default" |
| 0 | "default" | 0 |
| "" | "default" | "" |
| false | "default" | false |
| NaN | "default" | NaN |
| "value" | "value" | "value" |
function createSlider(options) {
const min = options.min ?? 0; // 0 if missing, but a passed 0 is kept as well
const max = options.max ?? 100;
const label = options.label ?? "Volume"; // "" is a valid (empty) label
const animate = options.animate ?? true; // false must not become true
return { min, max, label, animate };
}
console.log(createSlider({ min: -10, animate: false, label: "" }));
// { min: -10, max: 100, label: "", animate: false }
?? cannot be combined directly with && or ||: a || b ?? c is a SyntaxError. Write (a || b) ?? c to make the intended grouping explicit.
Optional chaining (?.). Accessing a property of null or undefined throws a TypeError. Optional chaining (ES2020) checks the value before the ?.: if it is nullish, the whole expression stops and evaluates to undefined instead of throwing.
| Form | Meaning |
|---|---|
obj?.prop | Property access |
obj?.[expr] | Computed property access |
fn?.(args) | Call only if fn is not nullish |
obj.method?.() | Call the method only if it exists |
const order = { customer: { address: null }, items: [] };
// Before ES2020
const cityOld = order && order.customer && order.customer.address && order.customer.address.city;
// With optional chaining
const city = order?.customer?.address?.city; // undefined, no TypeError
console.log(city ?? "Unknown city"); // "Unknown city": ?. and ?? work well together
console.log(order.items?.[0]?.name); // undefined
order.onShip?.(); // not called, because onShip is undefined
// Short-circuit applies to the rest of the chain
const n = null;
console.log(n?.a.b.c); // undefined: .b.c is never evaluated
Optional chaining has limits. It cannot appear on the left of an assignment (obj?.a = 1 is a SyntaxError). It only guards against null and undefined: if obj.method exists but is not a function, obj.method?.() still throws. And using ?. everywhere hides real bugs: if order must always exist, write order.customer so that a missing order fails loudly where it happens.
Logical assignment operators (ES2021). These combine a logical operator with assignment, and they also short-circuit: if the condition is not met, no assignment happens at all (so setters are not triggered and frozen objects do not throw).
| Operator | Equivalent to | Assigns when the left side is... |
|---|---|---|
| `a | = b` | |
a &&= b | a && (a = b) | Truthy |
a ??= b | a ?? (a = b) | null or undefined |
const cache = {};
function getUser(id) {
cache[id] ??= { id, loadedAt: Date.now() }; // create the entry only the first time
return cache[id];
}
const opts = { retries: 0, timeout: undefined };
opts.retries ??= 3; // stays 0: 0 is not nullish
opts.timeout ??= 5000; // becomes 5000
let token = "abc";
token &&= token.trim(); // transform only if present
Key Takeaways
&&and||short-circuit and return the operand that decided the result, not necessarily a boolean.||replaces every falsy value.??replaces onlynullandundefined, so it is the correct choice for defaults when0,""orfalseare valid.?.stops a property access or call chain at a nullish value and returnsundefined. It cannot be used as an assignment target.||=,&&=and??=assign only when needed, and skip the assignment entirely otherwise.??cannot be mixed with&&or||without parentheses. Do not use?.to hide values that should always exist.
Practice
- (Beginner) Rewrite
const port = config && config.server && config.server.port ? config.server.port : 8080;with?.and??. Test it withport: 0. Does the original handle that case correctly?- (Intermediate) Write a
memoize(fn)helper for single-argument functions that uses aMapand??=-style logic. Explain whycache[key] ||= fn(key)would recompute results for keys whose result is0or"".- (Interview) "What is the difference between
a || banda ?? b, and why doesa ?? b || cthrow a syntax error?" Hint: compare the sets of values that trigger the fallback, and consider why the language designers forced an explicit grouping.
Conditional Statements and switch
Programs need to choose between paths: show an error or a success message, apply a discount or not, route a request to the right handler. JavaScript offers if...else, the conditional operator ? : and the switch statement for this. The choice between them is mostly about readability, but switch has two traps (fall-through and shared scope) that are worth learning in advance.
if, else if, else. The condition is converted with ToBoolean (see Truthy and Falsy Values). Always use braces, even for single statements: it prevents bugs when a second line is added later.
function shippingCost(country, total) {
if (total >= 100) {
return 0; // free shipping over 100
} else if (country === "IT") {
return 5;
} else if (["FR", "DE", "ES"].includes(country)) {
return 9;
} else {
return 20;
}
}
Guard clauses. Deeply nested if blocks are hard to follow. Handle the special and error cases first, return early, and leave the main logic unindented.
// Nested
function publish(post, user) {
if (post) {
if (user.canPublish) {
if (!post.published) {
post.published = true;
return "published";
} else return "already published";
} else return "forbidden";
} else return "no post";
}
// Guard clauses: same logic, flat structure
function publishFlat(post, user) {
if (!post) return "no post";
if (!user.canPublish) return "forbidden";
if (post.published) return "already published";
post.published = true; // the "happy path" reads top to bottom
return "published";
}
The conditional (ternary) operator. condition ? valueIfTrue : valueIfFalse is an expression, so it can appear where if cannot: in a variable initialiser, a template literal, an argument or a return value.
const label = count === 1 ? "item" : "items";
const cls = `btn ${isPrimary ? "btn-primary" : "btn-secondary"}`;
Nested ternaries quickly become unreadable. If you need more than one level, use if statements or a lookup table.
switch. switch compares one value against several cases using strict equality (===). Execution jumps to the first matching case and continues until a break (or return, or throw), even through the following cases. This fall-through is occasionally useful for grouping cases, and frequently a bug.
function httpCategory(status) {
switch (Math.floor(status / 100)) {
case 2:
return "success";
case 3:
return "redirect";
case 4:
case 5: // grouped cases: intentional fall-through
return "error";
default: // runs when nothing matches; can be anywhere, usually last
return "unknown";
}
}
function describe(day) {
let kind;
switch (day) {
case "sat":
kind = "weekend"; // BUG: missing break, falls through to the next case
case "mon":
kind = "weekday";
break;
}
return kind;
}
console.log(describe("sat")); // "weekday"
Scope inside switch. All case clauses share a single block scope, so declaring the same let or const in two cases is a SyntaxError, and a declaration in one case is in its TDZ in the others. Wrap each case body in braces to give it its own scope.
switch (action.type) {
case "add": { // braces create a block per case
const item = action.payload;
cart.push(item);
break;
}
case "remove": {
const item = action.payload; // no clash with the declaration above
cart = cart.filter((x) => x.id !== item.id);
break;
}
}
switch (true). Since cases can be any expression, switch (true) matches the first case expression that evaluates to exactly true. It works but surprises many readers; an if...else if chain is usually clearer.
Lookup tables. When each branch only maps an input to a value or a function, an object or a Map is shorter than switch and can be extended or passed around as data.
const handlers = {
add: (cart, item) => [...cart, item],
remove: (cart, item) => cart.filter((x) => x.id !== item.id),
clear: () => [],
};
function reducer(cart, action) {
const handler = Object.hasOwn(handlers, action.type) ? handlers[action.type] : null;
return handler ? handler(cart, action.payload) : cart; // unknown actions leave cart unchanged
}
Object.hasOwn matters here: without it, action.type === "toString" would find the inherited Object.prototype.toString.
Choosing a construct.
| Situation | Best fit |
|---|---|
| One or two conditions, or conditions on different variables | if / else if |
| Choosing between two values in an expression | Ternary |
| Many discrete values of one variable, with statements in each branch | switch with break (or return) in every case |
| Pure mapping from a key to a value or function | Object or Map lookup table |
| Validation and error cases before the main logic | Guard clauses with early return |
Key Takeaways
ifuses truthiness. Always use braces, and prefer guard clauses with early returns over deep nesting.- The ternary operator is an expression. Keep it to one level.
switchuses===and falls through untilbreak,returnorthrow. Every non-empty case needs one of them unless fall-through is intentional and commented.- All
caseclauses share one scope. Wrap case bodies in{ }when they declare variables. - For pure key-to-value or key-to-function mapping, a lookup object or
Mapis often cleaner thanswitch.
Practice
- (Beginner) Write
grade(score)that returns"A"to"F"for scores from 0 to 100, first withif...else if, then with guard clauses that reject non-numbers and out-of-range values.- (Intermediate) Convert a 6-case
switchthat maps keyboard keys ("ArrowUp","w", ...) to movement vectors into a lookup table. Handle unknown keys and make sure that a key named"constructor"does not match anything.- (Interview) "What does this print, and why:
switch ('1') { case 1: console.log('number'); case '1': console.log('string'); default: console.log('default'); }?" Hint: think about the comparison algorithmswitchuses and what happens after a match withoutbreak.
Loops and Iteration Statements
Repeating work over a range of numbers, the items of a list or the keys of an object is one of the most common tasks in any program. JavaScript has five loop statements plus array iteration methods, and they are not interchangeable. In particular, for...in and for...of look almost identical but iterate over completely different things, and choosing the wrong one is a classic bug.
The loop statements.
| Loop | Iterates over | Typical use |
|---|---|---|
for (init; cond; step) | A counter you control | Index ranges, stepping backwards or by 2, early exit |
while (cond) | Until the condition becomes false | Unknown number of iterations, such as reading until a queue is empty |
do { } while (cond) | Same, but the body runs at least once | Retry and prompt loops |
for (const v of iterable) | Values of an iterable | Arrays, strings, Map, Set, NodeList, generators |
for (const k in object) | Enumerable string keys, including inherited ones | Plain objects (with care) |
array.forEach(fn) | Array elements via a callback | Side effects per element; cannot break |
// Classic for: full control over the index
for (let i = 10; i > 0; i -= 2) console.log(i); // 10 8 6 4 2
// while: unknown number of iterations
const queue = ["a", "b", "c"];
while (queue.length > 0) {
const job = queue.shift();
console.log("processing", job);
}
// do...while: body runs at least once
let attempt = 0;
do {
attempt++;
} while (attempt < 3 && Math.random() < 0.5);
for...of versus for...in.
const colours = ["red", "green"];
colours.extra = "oops"; // a non-index property on the array
for (const c of colours) console.log(c); // "red", "green": values of the iterable
for (const k in colours) console.log(k); // "0", "1", "extra": string keys, extra included!
for (const ch of "hi") console.log(ch); // "h", "i"
const prices = { apple: 1.2, pear: 0.9 };
// for (const p of prices) {} // TypeError: prices is not iterable
for (const [fruit, price] of Object.entries(prices)) {
console.log(`${fruit}: ${price}`); // iterate a plain object via entries
}
const scores = new Map([["ada", 10], ["linus", 7]]);
for (const [name, score] of scores) console.log(name, score); // Maps yield [key, value]
for...in for...of
works on any object iterables (Symbol.iterator, see 3.3)
yields keys (always strings) values
inherited yes, enumerable inherited keys n/a
on arrays indices as "0", "1" + extras elements, in order
on objects own + inherited keys TypeError (use Object.keys/values/entries)
Use for...of for arrays and other iterables. Use Object.keys, Object.values or Object.entries with for...of for plain objects; they return only own enumerable keys. Reserve for...in for the rare cases where you want inherited keys too.
break, continue and labels. break exits the innermost loop (or switch), and continue skips to the next iteration. To break out of an outer loop from an inner one, label the outer loop.
const grid = [
[1, 2, 3],
[4, -1, 6],
[7, 8, 9],
];
let found = null;
search: for (let r = 0; r < grid.length; r++) { // label the outer loop
for (let c = 0; c < grid[r].length; c++) {
if (grid[r][c] < 0) {
found = { r, c };
break search; // exits both loops
}
}
}
console.log(found); // { r: 1, c: 1 }
If you find yourself using labels often, extracting the inner logic into a function and using return is usually more readable.
Loops and array methods. Arrays have iteration methods (forEach, map, filter, find, some, every, reduce, covered in Chapter 4) that express intent better than a loop when you are transforming or querying data. Loops remain the right tool when you need break, await inside the body in sequence, or complex control flow. Note that return inside a forEach callback only ends that callback, not the iteration, and break is a syntax error there.
Common pitfalls.
| Pitfall | Example | Fix |
|---|---|---|
| Off-by-one | i <= arr.length | Use <, or for...of |
| Modifying an array while iterating it | splice inside for over the same array | Iterate backwards, or build a new array with filter |
| Infinite loop | Forgetting to update the condition variable | Update it in one obvious place |
Callbacks capturing var | var i with setTimeout | Use let (see var, let and const) |
| Re-reading an expensive length | i < getItems().length | Compute once before the loop |
for...in on arrays | Inherited or extra keys, string indices | Use for...of |
for await...of, which iterates over asynchronous data sources, is covered in Chapter 5.
Key Takeaways
forgives full control of a counter;whileanddo...whileloop until a condition changes;do...whilealways runs once.for...ofiterates the values of iterables.for...initerates string keys of any object, including inherited ones. Do not usefor...inon arrays.- Iterate plain objects with
Object.keys,Object.valuesorObject.entriescombined withfor...of. breakandcontinueaffect the innermost loop. Labels let you target an outer loop.forEachcannot be stopped withbreak. Use a loop orsome/findwhen you need early exit.
Practice
- (Beginner) Print a multiplication table from 1 to 10 using nested
forloops, then print only the rows for odd numbers usingcontinue.- (Intermediate) Given
const inventory = { apples: 3, pears: 0, plums: 7 }, log every fruit with a quantity above zero in three ways:for...inwithObject.hasOwn,Object.entrieswithfor...of, andObject.entrieswithfilterandforEach. Explain why the first version needsObject.hasOwn.- (Interview) "What is the difference between
for...inandfor...of?" Hint: compare what each one iterates (keys or values), which objects each works on, and how each treats inherited properties.
Destructuring Assignment
Code often needs to pull several values out of an object or array into separate variables: the x and y of a point, the status and data of a response, the first and second elements of a pair. Writing const status = res.status; const data = res.data; is repetitive. Destructuring (ES2015) lets you write a pattern that mirrors the shape of the data on the left of an assignment, and JavaScript fills in the variables.
const { name, age } = { name: "Ada", age: 36, city: "London" };
\_________/
pattern mirrors the object shape -> name = "Ada", age = 36
const [first, second] = ["red", "green", "blue"];
\___________/
pattern mirrors the array positions -> first = "red", second = "green"
Array destructuring. Elements are matched by position. It works on any iterable, not just arrays.
const rgb = [255, 128, 0];
const [r, g, b] = rgb; // r = 255, g = 128, b = 0
const [, second] = rgb; // skip elements with empty slots: second = 128
const [head, ...tail] = rgb; // rest element: head = 255, tail = [128, 0]
const [x = 0, y = 0, z = 0, w = 1] = rgb; // defaults for missing positions: w = 1
let a = 1, c = 2;
[a, c] = [c, a]; // swap without a temporary variable
const [firstChar] = "hello"; // strings are iterable: "h"
const [[k, v]] = new Map([["id", 7]]); // nested pattern on a Map: k = "id", v = 7
Object destructuring. Properties are matched by name, in any order.
const response = {
status: 200,
data: { user: { id: 7, name: "Ada" }, tags: ["admin"] },
headers: { "content-type": "application/json" },
};
const { status } = response; // status = 200
const { status: code } = response; // rename: code = 200 (no "status" variable)
const { timeout = 3000 } = response; // default: timeout = 3000
const { retries: maxRetries = 2 } = response; // rename and default together
const { data: { user: { name } } } = response; // nested: name = "Ada" ("data" is not a variable)
const { data: { tags: [firstTag] } } = response; // mixed object and array patterns: "admin"
const { headers: { "content-type": contentType } } = response; // non-identifier keys need renaming
const key = "status";
const { [key]: dynamic } = response; // computed key: dynamic = 200
const { headers, ...rest } = response; // rest: rest = { status, data }
| Pattern | Meaning |
|---|---|
{ a } | Variable a from property a |
{ a: b } | Variable b from property a ("property: target") |
{ a = 1 } | Default if a is undefined |
{ a: b = 1 } | Rename and default |
{ a: { b } } | Nested: variable b from a.b |
{ [expr]: v } | Computed property name |
{ ...rest } | Remaining own enumerable properties (must be last) |
Rules and pitfalls.
- Defaults apply only when the value is
undefined, notnull(see Booleans, null and undefined). - Destructuring
nullorundefinedthrows:const { a } = nullis aTypeError. Nested patterns throw if an intermediate level is missing, so give intermediate levels defaults:const { data: { user } = {} } = res. - When assigning to existing variables with an object pattern, wrap the statement in parentheses. Otherwise the
{is parsed as the start of a block:({ a, b } = obj);. - A line that starts with
[or(can be joined to the previous line if that line has no semicolon.[a, c] = [c, a]after a line without;is a common ASI bug.
Destructuring function parameters. This is the most common use of destructuring in modern code. It gives functions named, optional arguments with defaults, and makes the expected shape visible in the signature.
function createUser({ name, role = "viewer", active = true } = {}) { // "= {}" allows createUser()
return { name, role, active };
}
console.log(createUser({ name: "Ada", role: "admin" })); // { name: "Ada", role: "admin", active: true }
console.log(createUser()); // { name: undefined, role: "viewer", active: true }
// Arrays of pairs from Object.entries or Map, destructured in the loop header
const stock = { apples: 3, pears: 0 };
for (const [fruit, qty] of Object.entries(stock)) {
console.log(fruit, qty);
}
// Callbacks
const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }];
console.log(users.map(({ name }) => name)); // ["Ada", "Linus"]
// Functions that "return multiple values"
function minMax(nums) {
return [Math.min(...nums), Math.max(...nums)];
}
const [lo, hi] = minMax([4, 9, 1]); // lo = 1, hi = 9
Return an array when the values have an obvious order (like React's useState returning [value, setValue]) and an object when they are better identified by name.
Key Takeaways
- Array destructuring matches by position and works on any iterable. Object destructuring matches by property name.
{ a: b }renames: the left side is the property, the right side is the variable. Defaults use=and apply only toundefined.- Rest patterns (
...rest) collect the remaining elements or properties and must come last. - Destructuring
nullorundefinedthrows. Give parameters and nested levels a default of{}where data may be missing. - Parameter destructuring gives functions named, optional arguments with defaults.
Practice
- (Beginner) Given
const movie = { title: "Alien", year: 1979, ratings: [8.5, 9.1] }, extracttitle,yearrenamed toreleased, and the first rating, all in one statement.- (Intermediate) Write
formatAddress({ street, city, zip, country = "Italy" } = {})that returns a formatted multi-line string. Make it safe to call with no argument, withnullvalues for some fields, and with a nestedgeo: { lat, lng }object that may be missing.- (Interview) "Why does
{ a, b } = obj;throw a syntax error whileconst { a, b } = obj;works?" Hint: think about how the parser interprets a{at the start of a statement.
Spread and Rest Syntax
Combining arrays, copying objects, passing a list of values to a function, and accepting any number of arguments are everyday tasks. Before ES2015 they needed concat, apply, Object.assign and the arguments object. The ... syntax replaces all of them. The same three dots do two opposite jobs depending on where they appear: spread expands a collection into individual elements, and rest collects individual elements into a collection.
SPREAD: expands (on the right side, in calls and literals)
[...[1, 2], 3] -> [1, 2, 3]
f(...[1, 2]) -> f(1, 2)
{ ...{ a: 1 }, b: 2 } -> { a: 1, b: 2 }
REST: collects (on the left side, in parameters and patterns)
function f(...args) -> f(1, 2, 3) gives args = [1, 2, 3]
const [h, ...t] = arr -> t = everything after h
const { a, ...o } = obj -> o = every property except a
| Spread | Rest | |
|---|---|---|
| Job | Unpacks a collection | Packs values into a collection |
| Appears in | Array literals, object literals, function calls | Function parameters, destructuring patterns |
| How many per expression | Any number, any position | One, and it must be last |
| Arrays accept | Any iterable | |
| Objects accept | Any value (own enumerable properties are copied) |
Spread in arrays and calls. Spread in an array literal or argument list accepts any iterable: arrays, strings, Set, Map, NodeList, generators.
const a = [1, 2];
const b = [3, 4];
const merged = [...a, ...b]; // [1, 2, 3, 4]
const withExtras = [0, ...a, 2.5, ...b]; // insert anywhere
const copy = [...a]; // shallow copy: a new array
const unique = [...new Set([3, 1, 3, 2, 1])]; // [3, 1, 2]: deduplicate
const chars = [..."hey"]; // ["h", "e", "y"]
const divs = [...document.querySelectorAll("div")]; // NodeList to a real array
console.log(Math.max(...[4, 9, 1])); // 9: spread into arguments (replaces Math.max.apply)
// [...{ a: 1 }]; // TypeError: object is not iterable
Spreading a very large array (hundreds of thousands of elements) into a function call can exceed the engine's argument limit and throw a RangeError. Use reduce or a loop for huge data sets.
Spread in objects (ES2018). Object spread copies the source's own enumerable properties, including symbol keys, into the new object. Later properties overwrite earlier ones with the same key, so the order expresses priority.
const defaults = { theme: "light", fontSize: 14, beta: false };
const userPrefs = { fontSize: 18 };
const settings = { ...defaults, ...userPrefs }; // { theme: "light", fontSize: 18, beta: false }
const forced = { ...userPrefs, ...defaults }; // order matters: defaults win, fontSize = 14
const updated = { ...settings, theme: "dark" }; // immutable update: new object, one field changed
// Conditional properties: spreading false, null or undefined adds nothing
const isAdmin = false;
const query = { page: 1, ...(isAdmin && { includeDeleted: true }) }; // { page: 1 }
console.log({ ..."ab" }); // { 0: "a", 1: "b" }: strings spread their indices
console.log({ ...null }); // {}: no error, unlike array spread
Object spread does not copy the prototype, non-enumerable properties or getters as getters: getters are called and their current values copied as plain data properties. It is equivalent to Object.assign({}, source) except that it defines properties instead of assigning them, so setters on the target are not triggered.
Spread makes shallow copies. Only the top level is new. Nested objects and arrays are still shared between the copy and the original.
const original = { name: "Ada", address: { city: "London" } };
const shallow = { ...original };
shallow.name = "Grace"; // independent: original.name is still "Ada"
shallow.address.city = "Paris"; // shared: original.address.city is now "Paris"!
const deep = structuredClone(original); // deep copy for plain data (Chapter 4)
original ---> { name: "Ada", address: ref --+
+--> { city: "Paris" } <- shared
shallow ---> { name: "Grace", address: ref --+
Rest parameters. A rest parameter collects all remaining arguments into a real array. It replaces the old arguments object (see Parameters, Default Values and arguments).
function sum(...numbers) { // numbers is a real array
return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
console.log(sum()); // 0
function log(level, ...messages) { // fixed parameters first, rest last
console.log(`[${level.toUpperCase()}]`, ...messages); // rest in, spread out
}
log("warn", "Disk", "almost", "full"); // "[WARN] Disk almost full"
// function bad(...a, b) {} // SyntaxError: rest parameter must be last
Rest in destructuring. Rest elements in patterns collect "everything else". A common pattern removes properties without mutating the original object.
const user = { id: 7, name: "Ada", password: "s3cret", role: "admin" };
const { password, ...publicUser } = user; // omit a property
console.log(publicUser); // { id: 7, name: "Ada", role: "admin" }
const [winner, ...others] = ["Ada", "Linus", "Grace"];
console.log(others); // ["Linus", "Grace"]
Key Takeaways
...spreads (expands) in array literals, object literals and calls, and rests (collects) in parameters and destructuring patterns.- Array spread and call spread accept any iterable. Object spread copies own enumerable properties; later keys win.
- Spread copies are shallow: nested objects are shared. Use
structuredClonefor deep copies of plain data. - Rest parameters produce a real array and must be the last parameter. Prefer them to
arguments. const { secret, ...rest } = objomits properties without mutating, and...(cond && {...})adds properties conditionally.
Practice
- (Beginner) Write
last(...items)that returns the last argument, andconcatAll(...arrays)that merges any number of arrays into one using spread.- (Intermediate) Write an immutable
updateIn(state, userId, changes)that returns a newstateobject in whichstate.users[userId]haschangesmerged in, without mutating the original at any level. Verify thatstate !== newState, that the changed user is a new object, and that unchanged users are the same objects as before.- (Interview) "Is
{ ...obj }a deep or a shallow copy, and what else does it not copy?" Hint: consider nested objects, the prototype, non-enumerable properties, and what happens to getters.
<a id="33-functions"></a>
3.3 Functions
This subchapter covers functions, the most important building block in JavaScript: the ways to define them, how they receive arguments, how they capture their surroundings in closures, how this can be controlled, and the patterns built on top of them.
Function Declarations, Expressions and Hoisting
A function packages a piece of logic under a name so it can be reused, tested and reasoned about in isolation. It also creates a new scope, which keeps its internal variables away from the rest of the program. JavaScript offers several syntaxes for creating functions. They are not just stylistic variants: they differ in hoisting, naming, this behaviour and whether they can be used as constructors.
The ways to create a function.
// 1. Function declaration: a statement that creates a named function
function add(a, b) {
return a + b;
}
// 2. Function expression: a function used as a value (here, anonymous)
const subtract = function (a, b) {
return a - b;
};
// 3. Named function expression: the name is visible only inside the function
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1); // "fact" is usable here for recursion
};
// fact(3); // ReferenceError: fact is not defined out here
// 4. Arrow function (see Arrow Functions and Lexical this)
const multiply = (a, b) => a * b;
// 5. Method shorthand in an object literal or class
const calculator = {
divide(a, b) { return a / b; },
};
// 6. Generator and async variants (see Generators and Iterators, and Chapter 5)
function* ids() { yield 1; }
async function load() {}
// 7. The Function constructor: compiles a string at run time. Avoid it (like eval).
const unsafe = new Function("a", "b", "return a + b");
Declarations versus expressions. The distinction is about where the function keyword appears. If a statement starts with function, it is a declaration. If function appears anywhere an expression is expected (the right side of =, an argument, inside parentheses), it is an expression.
| Declaration | Expression (const f = function...) | Arrow (const f = () => ...) | |
|---|---|---|---|
| Hoisted | Yes, completely | Variable rules: TDZ for const/let, undefined for var | Same as expression |
| Callable before its line | Yes | No | No |
| Must have a name | Yes | No (name inferred from the variable) | No (name inferred) |
Own this and arguments | Yes | Yes | No |
Usable with new | Yes | Yes | No |
Hoisting in practice.
console.log(square(4)); // 16: the declaration is hoisted with its body
// console.log(cube(2)); // ReferenceError: Cannot access 'cube' before initialization (TDZ)
// console.log(half(8)); // TypeError: half is not a function (var is undefined here)
function square(n) { return n * n; }
const cube = (n) => n ** 3;
var half = function (n) { return n / 2; };
Declaration hoisting lets you put the main logic at the top of a file and helper functions below it, which some teams like for readability. Function expressions with const force a "define before use" order, which others prefer. Either is fine; be consistent within a codebase.
Function names. Every function has a name property, used in stack traces and debugging tools. For anonymous expressions and arrows, the engine infers the name from the variable, property or parameter default it is assigned to.
const greet = () => {};
console.log(greet.name); // "greet": inferred
const obj = { run: function () {} };
console.log(obj.run.name); // "run"
console.log((() => {}).name); // "": truly anonymous
Named function expressions are useful for recursion that must not depend on the outer variable (which might be reassigned), and for clearer stack traces in callbacks.
Functions in blocks. In strict mode (including modules and classes), a function declaration inside a block is block-scoped, like let. In sloppy mode, legacy web-compatibility rules (Annex B of the specification) also create a var-like binding in the enclosing function, which leads to confusing behaviour. Avoid declaring functions inside if blocks; assign a function expression to a variable instead.
Return values. A function without a return statement, or with a bare return;, returns undefined. Beware of automatic semicolon insertion (ASI): a line break directly after return ends the statement.
function makeConfig() {
return // ASI inserts a semicolon here
{
debug: true,
};
}
console.log(makeConfig()); // undefined
function makeConfigFixed() {
return { // opening brace on the same line as return
debug: true,
};
}
Functions are objects. Functions can be stored, passed around and given properties. Besides name, they have length (the number of declared parameters before the first default or rest parameter) and, for non-arrow functions, a prototype property used by constructors (Chapter 4).
Key Takeaways
- A statement starting with
functionis a declaration. Afunctionanywhere an expression is expected is a function expression. - Declarations are hoisted completely and can be called before their line. Expressions follow the hoisting rules of the variable they are assigned to.
- Named function expressions expose their name only inside the function, which is useful for recursion and stack traces. Anonymous functions get an inferred
name. - Never put
returnon its own line before the returned value. ASI will make the function returnundefined. - Avoid the
Functionconstructor and function declarations inside blocks in sloppy-mode code.
Practice
- (Beginner) Write the same
isEven(n)function as a declaration, a function expression and an arrow function. Call each one before and after its definition and record which calls fail and with which error.- (Intermediate) Write a recursive named function expression
countdownand assign it to a variablerun. Then setrunto a different function reference and explain why the recursion still works. What would break if the function calledrun(n - 1)instead of its own name?- (Interview) "What is the difference between a function declaration and a function expression?" Hint: cover hoisting, naming, and where each one can appear syntactically.
Parameters, Default Values and arguments
A function's signature is its contract with callers. In many languages, calling a function with the wrong number of arguments is a compile-time error. JavaScript is far more relaxed: any function can be called with any number of arguments. Understanding what happens to missing and extra arguments, how defaults are evaluated, and how arguments are passed (by value or by reference) prevents a whole family of subtle bugs.
Parameters and arguments. Parameters are the names in the function definition; arguments are the values passed in a call. JavaScript does not check that their numbers match:
function describe(name, age) {
return `${name} (${age})`;
}
console.log(describe("Ada")); // "Ada (undefined)": missing parameters are undefined
console.log(describe("Ada", 36, "extra")); // "Ada (36)": extra arguments are ignored
Default parameter values (ES2015). A default is used when the argument is undefined (missing or explicitly undefined), never for null, 0 or "".
function connect(host = "localhost", port = 8080, retries = port > 1024 ? 3 : 1) {
// ^ defaults can use earlier parameters
return `${host}:${port} (retries: ${retries})`;
}
console.log(connect()); // "localhost:8080 (retries: 3)"
console.log(connect(undefined, 80)); // "localhost:80 (retries: 1)": skip a parameter with undefined
console.log(connect(null, 80)); // "null:80 (retries: 1)": null is a real value
Defaults are evaluated at call time, every time, from left to right. A default can call a function, refer to earlier parameters, and create a fresh object for each call. (Python programmers will recognise this as the opposite of Python's shared mutable defaults.)
function addItem(item, list = []) { // a new array on every call that needs the default
list.push(item);
return list;
}
console.log(addItem("a")); // ["a"]
console.log(addItem("b")); // ["b"], not ["a", "b"]
// A "required parameter" helper: the default runs only when the argument is missing
const required = (name) => { throw new TypeError(`Missing argument: ${name}`); };
function createUser(email = required("email"), role = "viewer") {
return { email, role };
}
// createUser(); // TypeError: Missing argument: email
Rest parameters. ...args collects the remaining arguments into a real array (see Spread and Rest Syntax). It must be the last parameter.
The arguments object. Every non-arrow function has an implicit local arguments object that holds all the passed arguments. It predates rest parameters and has several drawbacks:
arguments | Rest parameters ...args | |
|---|---|---|
| Type | Array-like object (has length and indices, no array methods) | Real Array |
| Contents | All arguments | Only arguments not matched by earlier parameters |
| Available in arrow functions | No (refers to the enclosing function's arguments) | Yes |
| Linked to named parameters | In sloppy mode with simple parameters: yes (changing one changes the other) | No |
| Readability | Hidden: the signature does not show it | Explicit in the signature |
function legacySum() {
// arguments.reduce(...) // TypeError: arguments.reduce is not a function
return Array.from(arguments).reduce((a, b) => a + b, 0); // convert first
}
function modernSum(...nums) {
return nums.reduce((a, b) => a + b, 0); // already an array
}
function sloppyAlias(a) { // sloppy mode, simple parameter list
arguments[0] = 99;
return a; // 99: a and arguments[0] are linked
}
Use rest parameters in new code. You will still see arguments in older libraries.
function.length. The length property counts parameters before the first one with a default or the rest parameter. Libraries such as curry helpers (see Currying and Function Composition) rely on it, so defaults can change their behaviour.
console.log(((a, b, c) => {}).length); // 3
console.log(((a, b = 1, c) => {}).length); // 1
console.log(((...args) => {}).length); // 0
How arguments are passed: "call by sharing". JavaScript always passes a copy of the value held by the argument. For primitives, that is the value itself. For objects, the value is a reference, so the function receives a copy of the reference: it can mutate the shared object, but reassigning the parameter does not affect the caller.
function update(count, user) {
count = count + 1; // changes the local copy only
user.visits += 1; // mutates the shared object: visible to the caller
user = { visits: 0 }; // rebinds the local parameter only: the caller is unaffected
}
let count = 1;
const user = { visits: 1 };
update(count, user);
console.log(count, user.visits); // 1 2
caller function update
count: 1 --- copy ------> count: 1 -> 2 (independent)
user: ref --- copy -----> user: ref --+
| | user.visits += 1 follows the reference
+-----> { visits: 2 } <----------+
user = {...} only changes the local copy
To avoid surprising callers, prefer functions that return new objects over functions that mutate their arguments.
Named parameters with destructuring. Functions with several optional parameters are hard to call positionally (createChart(data, null, null, true)). Accept an options object and destructure it with defaults:
function createChart(data, { type = "bar", width = 600, height = 400, legend = true } = {}) {
return { data, type, width, height, legend };
}
createChart([1, 2, 3], { legend: false }); // self-documenting call, any order, any subset
Key Takeaways
- JavaScript does not enforce argument counts. Missing arguments are
undefined; extra arguments are ignored (but available through rest orarguments). - Default values apply only to
undefined, are evaluated on every call, and can refer to earlier parameters. - Prefer rest parameters to
arguments, which is array-like, unavailable in arrow functions and aliased to parameters in sloppy mode. - Arguments are passed by sharing: mutations to an object argument are visible to the caller, reassignment of the parameter is not.
- Use a destructured options object for functions with several optional parameters.
Practice
- (Beginner) Write
greet(name = "friend", greeting = "Hello")and call it with no arguments, withundefinedas the first argument, withnullas the first argument and with"". Explain each result.- (Intermediate) Rewrite
function oldMax() { var max = -Infinity; for (var i = 0; i < arguments.length; i++) if (arguments[i] > max) max = arguments[i]; return max; }using rest parameters and array methods, then add a check that throws aTypeErrorif any argument is not a finite number.- (Interview) "Is JavaScript pass-by-value or pass-by-reference?" Hint: explain with one example that mutates an object argument and one that reassigns it, and use the term "call by sharing".
Arrow Functions and Lexical this
Arrow functions (ES2015) are not just a shorter way to write function. They were designed to solve a specific, very common problem: inside a callback, the regular this value is lost. Before arrows, code was full of var self = this; and .bind(this) workarounds. An arrow function has no this of its own; it uses the this of the code around it, like any other variable from an outer scope. This makes arrows ideal for callbacks and unsuitable for methods.
Syntax.
const double = (x) => x * 2; // one parameter: parentheses optional, x => x * 2
const add = (a, b) => a + b; // expression body: the value is returned implicitly
const now = () => Date.now(); // no parameters: empty parentheses required
const clamp = (n, min, max) => { // block body: needs an explicit return
if (n < min) return min;
return n > max ? max : n;
};
const makePoint = (x, y) => ({ x, y }); // returning an object literal: wrap it in parentheses
const broken = (x, y) => { x, y }; // parsed as a block containing the expression "x, y": returns undefined
A line break between the parameter list and => is a SyntaxError.
How arrows differ from regular functions.
| Feature | Regular function | Arrow function |
|---|---|---|
this | Decided by how the function is called (Chapter 4) | Inherited from the enclosing scope, fixed forever |
arguments | Own arguments object | None (uses the enclosing function's) |
new | Can be used as a constructor | TypeError: not a constructor |
prototype property | Yes | No |
super, new.target | Own | Inherited from the enclosing scope |
| Can be a generator | Yes (function*) | No |
call/apply/bind can change this | Yes | No (the thisArg is ignored) |
The problem arrows solve. A regular function's this depends on how it is called. When you pass a method's inner callback to setTimeout, forEach or an event API, that callback is called as a plain function, so its this is undefined (strict mode) or the global object (sloppy mode).
class Timer {
constructor() {
this.seconds = 0;
}
startBroken() {
setInterval(function () {
this.seconds++; // TypeError in strict mode: this is undefined in a plain call
}, 1000);
}
startOldWay() {
const self = this; // pre-ES2015 workaround 1: capture this in a variable
setInterval(function () { self.seconds++; }, 1000);
// workaround 2: setInterval(function () { this.seconds++; }.bind(this), 1000);
}
start() {
setInterval(() => {
this.seconds++; // arrow: this is the Timer instance from start()
}, 1000);
}
}
start() is called as timer.start() -> this = timer
|
+-- arrow callback: no own this -> looks up the scope chain -> finds start()'s this = timer
startBroken() is called as timer.startBroken() -> this = timer
|
+-- function callback: own this, called by setInterval as a plain function -> this = undefined
When not to use arrow functions. Because their this is fixed to the surrounding scope, arrows are the wrong choice whenever a function needs a this determined by the call:
const counter = {
count: 0,
incBroken: () => {
this.count++; // this is the module/global this, NOT counter
},
inc() {
this.count++; // method shorthand: this is counter when called as counter.inc()
},
};
const button = document.querySelector("button");
button.addEventListener("click", function () {
this.classList.toggle("active"); // regular function: this is the element
});
button.addEventListener("click", (event) => {
event.currentTarget.classList.toggle("active"); // arrow: use event.currentTarget instead
});
| Use an arrow for | Use a regular function or method for |
|---|---|
Callbacks inside methods (map, then, setTimeout) | Object and class methods |
Short pure functions (x => x * 2) | Constructors |
Functions that should keep the outer this | Event handlers that rely on this being the element |
Generators, and functions that need arguments |
Arrow class fields. In classes, a common pattern defines event handlers as arrow-function class fields, so they are permanently bound to the instance and can be passed directly as callbacks. The trade-off: each instance gets its own copy of the function (rather than sharing one on the prototype), and subclasses cannot call it through super, because it is not on the prototype.
class SearchBox {
query = "";
onInput = (event) => { // a class field holding an arrow: this is always the instance
this.query = event.target.value;
};
attach(input) {
input.addEventListener("input", this.onInput); // safe to pass without bind
}
}
The full rules for this in regular functions are covered in Chapter 4 (The this Binding Rules).
Key Takeaways
- Arrow functions have concise syntax, with implicit return for expression bodies. Wrap a returned object literal in parentheses.
- Arrows have no own
this,arguments,superornew.target: they use those of the enclosing scope.call,applyandbindcannot change theirthis. - Arrows cannot be constructors or generators and have no
prototype. - Use arrows for callbacks that need the outer
this. Use regular methods for object and class methods and for handlers that rely onthis. - Arrow class fields give permanently bound handlers at the cost of one function per instance.
Practice
- (Beginner) Convert five small function expressions (including one that returns an object and one with a multi-statement body) to arrow functions, and check that each returns the same value.
- (Intermediate) Create an object
stopwatchwithstart,stopandelapsedmethods that usessetIntervalinternally. Write it first with afunctioncallback and fix thethisbug three ways: a captured variable,bind, and an arrow function.- (Interview) "Why can't you use an arrow function as an object method that accesses
this?" Hint: explain where an arrow'sthiscomes from at the moment the object literal is evaluated.
First-Class and Higher-Order Functions
In JavaScript, functions are first-class values: they can be stored in variables, put in arrays and objects, passed as arguments and returned from other functions, exactly like numbers or strings. This single property is what makes callbacks, event handlers, array methods such as map and filter, middleware and React hooks possible. A function that takes or returns another function is called a higher-order function.
What "first-class" means in practice.
const shout = (s) => s.toUpperCase() + "!";
const fn = shout; // assigned to a variable
const tools = { format: shout }; // stored in an object
const pipeline = [shout, (s) => `<${s}>`]; // stored in an array
console.log(pipeline.reduce((acc, f) => f(acc), "hi")); // "<HI!>": called from a collection
function applyTwice(f, x) { // passed as an argument
return f(f(x));
}
console.log(applyTwice(shout, "hey")); // "HEY!!"
function multiplier(factor) { // returned from a function
return (n) => n * factor;
}
const triple = multiplier(3);
console.log(triple(5)); // 15
Higher-order functions. A higher-order function (HOF) does at least one of these:
| Kind | Purpose | Built-in examples |
|---|---|---|
| Takes a function | Lets the caller customise one step of an algorithm | arr.map(fn), arr.filter(fn), arr.sort(compareFn), addEventListener(type, fn), setTimeout(fn) |
| Returns a function | Creates specialised functions, wraps behaviour | fn.bind(...), multiplier(3) above, decorators such as debounce (Chapter 5) |
The function passed in is often called a callback: the HOF "calls it back" at the right moment. The HOF owns the general structure (iterate, wait, retry); the callback supplies the specific behaviour.
From imperative to declarative. HOFs let you describe what you want rather than how to loop:
const orders = [
{ id: 1, total: 120, status: "paid" },
{ id: 2, total: 40, status: "pending" },
{ id: 3, total: 75, status: "paid" },
];
// Imperative: manage the loop, the index and the accumulator yourself
let paidTotal = 0;
for (let i = 0; i < orders.length; i++) {
if (orders[i].status === "paid") paidTotal += orders[i].total;
}
// Declarative: compose small, named operations
const isPaid = (o) => o.status === "paid";
const toTotal = (o) => o.total;
const sum = (a, b) => a + b;
const paidTotal2 = orders.filter(isPaid).map(toTotal).reduce(sum, 0); // 195
Array methods are covered in Chapter 4. Here the point is the pattern: the HOF (filter) handles iteration, and small functions (isPaid) express the business rules.
Writing your own HOFs. Many utilities are short HOFs that wrap another function to add behaviour.
// Implement map yourself to see how a callback is invoked
function myMap(array, callback) {
const result = [];
for (let i = 0; i < array.length; i++) {
result.push(callback(array[i], i, array)); // same arguments as Array.prototype.map
}
return result;
}
// once: run a function at most once (for example, an initialisation handler)
function once(fn) {
let done = false, result;
return function (...args) {
if (!done) {
done = true;
result = fn.apply(this, args); // preserve this and arguments (see call, apply and bind)
}
return result;
};
}
const init = once(() => console.log("initialised"));
init(); init(); // logs once
// withLogging: a decorator that adds behaviour around any function
const withLogging = (fn) => (...args) => {
console.log(`${fn.name}(${args.join(", ")})`);
const value = fn(...args);
console.log("->", value);
return value;
};
const loggedAdd = withLogging(function add(a, b) { return a + b; });
loggedAdd(2, 3); // "add(2, 3)" then "-> 5"
Pitfalls when passing functions.
// 1. Callbacks receive more arguments than you might expect
console.log(["1", "2", "3"].map(parseInt)); // [1, NaN, NaN]
// map calls parseInt("1", 0), parseInt("2", 1), parseInt("3", 2): the index is used as the radix
console.log(["1", "2", "3"].map((s) => parseInt(s, 10))); // [1, 2, 3]
console.log(["1", "2", "3"].map(Number)); // [1, 2, 3]
// 2. Passing a method detaches it from its object
const user = { name: "Ada", hello() { return `Hi, ${this.name}`; } };
const hello = user.hello;
// hello(); // TypeError: this is undefined in strict mode
setTimeout(() => console.log(user.hello()), 0); // wrap in an arrow, or use user.hello.bind(user)
Pure functions. HOFs work best with pure functions: functions whose result depends only on their arguments and which have no side effects (no mutation of outside state, no I/O). Pure functions are easy to test, cache, reorder and compose. Keep side effects (DOM updates, network calls) at the edges of your program and pure transformations in the middle.
Key Takeaways
- Functions are first-class values: they can be assigned, stored, passed and returned.
- A higher-order function takes and/or returns functions. Callbacks let the caller customise one step of a general algorithm.
- HOFs such as
filter,mapandreduceenable declarative code built from small, named functions. - Wrapper HOFs (
once, logging,debounce,memoize) add behaviour without changing the original function. Usefn.apply(this, args)to forwardthisand arguments. - Watch for callbacks receiving extra arguments (
map(parseInt)) and methods losingthiswhen passed as values.
Practice
- (Beginner) Implement
myFilter(array, predicate)andmyReduce(array, reducer, initial)without using the built-in methods, and test them against the built-ins.- (Intermediate) Write
memoize(fn)that caches results byJSON.stringify(args), andtimeIt(fn)that logs how long each call takes withperformance.now(). Compose them astimeIt(memoize(slowFib))and compare first and second calls.- (Interview) "Why does
['1', '2', '3'].map(parseInt)return[1, NaN, NaN]?" Hint: list the three argumentsmappasses to its callback and the second parameterparseIntaccepts.
Closures and Lexical Environments
Closures are often described as an advanced topic, but you have already used them: every callback that reads a variable from the function around it is a closure. A closure is what lets a function "remember" variables from the place it was created, even after that place has finished running. This is how JavaScript provides private state, function factories, memoisation and event handlers that know their context. To understand closures precisely, you need the concept of a lexical environment.
Lexical environments. Each time a scope is entered (a function call, a block, a module), the engine creates a lexical environment: a record of the variables declared in that scope plus a link to the outer environment. Variable lookup follows these links outward (this is the scope chain from Scope, Hoisting and the Temporal Dead Zone).
Crucially, when a function is created, it stores a hidden reference to the environment in which it was created (the specification calls this internal slot [[Environment]]). When the function is later called, its new environment's outer link points to that stored environment, not to the environment of the caller.
A closure is the combination of a function and the lexical environment it was created in. As long as the function is reachable, the environment it references (and the variables in it) stays alive.
function makeCounter() {
let count = 0; // lives in makeCounter's environment
return function increment() { // increment's [[Environment]] = makeCounter's environment
count++;
return count;
};
}
const counterA = makeCounter(); // makeCounter has returned, but its environment survives
const counterB = makeCounter(); // a second call creates a second, independent environment
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1: separate count
Global environment
makeCounter: <function>
counterA: <increment #1> ---+
counterB: <increment #2> ---|---+
| |
Environment of call #1 <---+ | Environment of call #2 <---+
count: 2 count: 1
outer -> Global outer -> Global
Each increment function keeps its own environment alive.
Nothing outside can reach "count" except through increment.
Closures capture variables, not values. A closure holds a reference to the variable binding itself, so it sees later changes and can make changes visible to other closures sharing the same environment.
function makeWallet() {
let balance = 0; // shared by both closures below
return {
deposit: (amount) => { balance += amount; },
getBalance: () => balance, // always reads the current value
};
}
const wallet = makeWallet();
wallet.deposit(50);
wallet.deposit(25);
console.log(wallet.getBalance()); // 75
console.log(wallet.balance); // undefined: truly private
This is also why the var loop bug from var, let and const occurs: all callbacks close over the same var i binding. With let, each iteration has its own environment, so each callback captures a different binding.
Common uses.
| Use | Idea | Example |
|---|---|---|
| Data privacy | Variables accessible only through returned functions | makeWallet above, the module pattern |
| Function factories | Generate specialised functions from parameters | multiplier(3), createValidator(rules) |
| Partial application and currying | Remember some arguments for a later call | See Currying and Function Composition |
| Memoisation | Keep a cache between calls | memoize(fn) |
| Callbacks and event handlers | Remember the context they were registered in | button.onclick = () => select(item.id) |
| Stateful wrappers | Remember state between calls | once, debounce, throttle |
Example: a factory with private configuration and cache
function createFormatter(locale, currency) {
const formatter = new Intl.NumberFormat(locale, { style: "currency", currency }); // built once
const cache = new Map(); // private cache
return function format(amount) {
if (!cache.has(amount)) cache.set(amount, formatter.format(amount));
return cache.get(amount);
};
}
const eur = createFormatter("it-IT", "EUR");
const usd = createFormatter("en-US", "USD");
console.log(eur(12345.5)); // "12.345,50 €"
console.log(usd(12345.5)); // "$12,345.50"
Closures and memory. A closure keeps its environment alive. That is the point, but it also means closures can keep large objects in memory longer than you expect, for example an event listener that closes over a big data array and is never removed. Modern engines only retain variables that some inner function actually references, but closures created in the same scope may share one environment object. Remove event listeners and clear timers you no longer need (Chapter 6 and Chapter 5), and avoid capturing large objects in long-lived callbacks.
Key Takeaways
- A lexical environment is a record of variables plus a link to the outer environment. Variable lookup follows these links.
- A function stores a reference to the environment where it was created. A closure is that function plus its environment.
- Closures keep variables alive after the outer function returns, and they capture bindings, not snapshots of values.
- Each call of the outer function creates a new environment, so each returned closure has independent state.
- Closures enable private state, factories, memoisation and stateful wrappers, but long-lived closures can retain memory.
Practice
- (Beginner) Write
makeIdGenerator(prefix)that returns a function producing"user-1","user-2", and so on. Create two generators with different prefixes and show that their counters are independent.- (Intermediate) Implement
createStack()that returnspush,pop,peekandsizemethods backed by a private array. Prove that the array cannot be modified from outside, and explain why returning the array from agetItems()method would break that privacy.- (Interview) "What is a closure? Give a practical example." Hint: define it in terms of a function and the environment where it was created, then describe a counter or a private-state module and explain why the variable survives after the outer function returns.
Immediately Invoked Function Expressions
Before 2015, JavaScript had no block scope and no modules. Every top-level var and function declaration in a script became a global, visible to and overwritable by every other script on the page. The Immediately Invoked Function Expression (IIFE) was the community's solution: wrap code in a function and call it immediately, so its variables live in the function's private scope. You will see IIFEs in older code, in bundler output, and in a few modern situations.
Anatomy. A function declaration cannot be called directly: function () {}() is a syntax error, because a statement that starts with function is parsed as a declaration. Wrapping the function in parentheses turns it into an expression, which can then be called.
( function () { ... } ) ( );
\_________________________/ \_/
function expression call it immediately
(function () {
const secret = "only visible in here";
console.log("runs immediately");
})();
(function () {
console.log("Douglas Crockford's preferred style: call parentheses inside");
}());
(() => {
console.log("arrow IIFE");
})();
(function (global, doc) { // pass dependencies in as parameters
global.appVersion = "1.0";
})(window, document);
const config = (() => { // compute a constant with complex logic, then discard the helpers
const env = location.hostname === "localhost" ? "dev" : "prod";
const base = env === "dev" ? "http://localhost:3000" : "https://api.example.com";
return Object.freeze({ env, base });
})();
Other operators that force expression context also work (!function () {}();, void function () {}();), and you will see them in minified code.
The module pattern. IIFEs combined with closures gave JavaScript its first modules: private state inside, a public API returned as an object.
const CartModule = (function () {
const items = []; // private
function total() { // private helper
return items.reduce((sum, i) => sum + i.price, 0);
}
return { // public API ("revealing module pattern")
add(item) { items.push(item); },
count() { return items.length; },
total,
};
})();
CartModule.add({ name: "Book", price: 12 });
console.log(CartModule.total()); // 12
console.log(CartModule.items); // undefined: private
The ASI pitfall. If the line before an IIFE does not end with a semicolon, the opening parenthesis is parsed as a call of the previous expression:
const a = 1
(function () {})() // parsed as: const a = 1(function () {})() -> TypeError: 1 is not a function
That is why some codebases start IIFEs with a defensive semicolon: ;(function () {})().
Modern alternatives.
| Historical use of an IIFE | Modern replacement |
|---|---|
| Private scope for a script's variables | ES modules: every module has its own scope (see ES Modules with import and export) |
| Avoiding leaking loop variables | let and const in blocks |
| Capturing a loop variable per iteration | let in the loop header |
| Module pattern | ES modules with export, or classes with private fields |
Where IIFEs are still useful.
- Async IIFE in environments without top-level
await(classic scripts, CommonJS):(async () => { const data = await load(); })(); - Initialising a
constwith complex logic without leaking temporary variables, as in theconfigexample above. - Bundler and library output: UMD and IIFE bundle formats wrap a whole library in an IIFE so it can be loaded with a plain
<script>tag.
Key Takeaways
- An IIFE is a function expression called immediately. Parentheses (or another operator) are needed so the parser treats
functionas an expression. - IIFEs create a private scope. Combined with closures, they produced the module pattern before ES modules existed.
- A missing semicolon on the line before an IIFE turns it into a call of the previous expression.
- Block scope and ES modules replace most historical uses of IIFEs.
- IIFEs remain useful for async entry points in classic scripts, complex
constinitialisation and library bundle formats.
Practice
- (Beginner) Wrap a small script that declares three
varvariables in an IIFE and verify with the console that they are no longer properties ofwindow.- (Intermediate) Write a
Countermodule with the revealing module pattern (privatecount, publicincrement,reset,value). Then rewrite it as an ES module and compare the two versions.- (Interview) "Why does
function () {}()throw a syntax error while(function () {})()works?" Hint: think about how the parser decides between a function declaration and a function expression at the start of a statement.
call, apply and bind
A regular function's this is not fixed when the function is written. It is decided each time the function is called, based on how it is called (obj.method() sets this to obj; a plain call fn() sets it to undefined in strict mode; Chapter 4 covers all the rules). Sometimes you need to choose this yourself: to borrow a method from another object, to pass a method as a callback without losing its object, or to pre-fill some arguments. Every function has three methods for this: call, apply and bind.
Comparison.
| Method | Calls the function? | How arguments are passed | Returns |
|---|---|---|---|
fn.call(thisArg, a, b, ...) | Immediately | One by one | The function's return value |
fn.apply(thisArg, [a, b, ...]) | Immediately | As an array (or array-like) | The function's return value |
fn.bind(thisArg, a, b, ...) | No | Pre-filled, one by one | A new bound function |
A mnemonic: apply takes an array, call takes commas, bind is for behaviour later.
function introduce(greeting, punctuation) {
return `${greeting}, I am ${this.name}${punctuation}`;
}
const ada = { name: "Ada" };
const linus = { name: "Linus" };
console.log(introduce.call(ada, "Hello", "!")); // "Hello, I am Ada!"
console.log(introduce.apply(linus, ["Hi", "."])); // "Hi, I am Linus."
const adaIntro = introduce.bind(ada, "Hey"); // this and the first argument are fixed
console.log(adaIntro("?")); // "Hey, I am Ada?"
Use case 1: method borrowing. Use a method from one object on another object that has the right shape but not the method.
function legacy() {
// arguments is array-like but has no array methods: borrow slice from Array.prototype
const args = Array.prototype.slice.call(arguments, 1);
return args;
}
console.log(legacy("a", "b", "c")); // ["b", "c"]
const nodeLike = { 0: "x", 1: "y", length: 2 };
console.log(Array.prototype.map.call(nodeLike, (s) => s.toUpperCase())); // ["X", "Y"]
// Reliable type tags: borrow Object.prototype.toString
console.log(Object.prototype.toString.call([])); // "[object Array]"
Modern code often uses Array.from(arrayLike) or spread instead, but method borrowing is still common in libraries and is the basis of mixins (Chapter 4).
Use case 2: apply with arrays. Before spread syntax, apply was the only way to pass an array as individual arguments. Spread has replaced it in most code.
const nums = [5, 1, 8];
console.log(Math.max.apply(null, nums)); // 8: legacy style
console.log(Math.max(...nums)); // 8: modern equivalent
Use case 3: bind for callbacks. Passing obj.method as a callback detaches it from obj. bind creates a function that always calls the original with the chosen this.
class Player {
constructor(name) {
this.name = name;
this.onClick = this.onClick.bind(this); // bind once in the constructor (common pre-arrow-field pattern)
}
onClick() {
console.log(`${this.name} clicked`);
}
}
const p = new Player("Ada");
document.querySelector("#play").addEventListener("click", p.onClick); // this stays p
setTimeout(p.onClick, 100); // works as well
Use case 4: partial application with bind. Arguments passed to bind after thisArg are fixed in front of any later arguments. If this does not matter, pass null.
const multiply = (a, b) => a * b;
const double = multiply.bind(null, 2); // arrow ignores thisArg; only the argument is fixed
console.log(double(21)); // 42
const logWarn = console.log.bind(console, "[warn]");
logWarn("disk almost full"); // "[warn] disk almost full"
Rules and edge cases.
| Rule | Example |
|---|---|
A bound function's this cannot be changed again by call, apply or another bind | fn.bind(a).call(b) still uses a |
Arrow functions ignore thisArg entirely | (() => this).call(obj) returns the outer this |
new on a bound function ignores the bound this but keeps bound arguments | new (Point.bind(null, 1))(2) creates a Point(1, 2) |
In sloppy mode, null/undefined thisArg becomes globalThis and primitives are boxed | Strict mode passes them through unchanged |
A bound function's name is "bound original" and its length is reduced by the bound arguments | multiply.bind(null, 2).length is 1 |
How bind works. A simplified implementation shows that bind is just a closure around apply:
Function.prototype.myBind = function (thisArg, ...boundArgs) {
const original = this; // the function myBind was called on
return function (...laterArgs) {
return original.apply(thisArg, [...boundArgs, ...laterArgs]); // fixed this, merged arguments
};
};
// (The real bind also handles new, name and length.)
Key Takeaways
callandapplyinvoke a function immediately with a chosenthis.calltakes arguments individually,applytakes an array.bindreturns a new function withthis(and optionally leading arguments) permanently fixed.- Use them for method borrowing, keeping
thisin callbacks, and partial application. Spread has replaced most uses ofapply. - Bound functions cannot be rebound, and arrow functions ignore
thisArg. bindis essentially a closure that callsapplywith the storedthisand arguments.
Practice
- (Beginner) Given
const car = { brand: "Fiat", describe(year) { return this.brand + " " + year; } }andconst bike = { brand: "Bianchi" }, calldescribeforbikeusingcall,applyandbind.- (Intermediate) Extend
myBindso that it also works withnew(the boundthismust be ignored and the result must be an instance of the original function). Test it with aPoint(x, y)constructor function.- (Interview) "What is the difference between
call,applyandbind, and when would you use each?" Hint: compare when the function runs, how arguments are passed, and what is returned; give one realistic use case for each.
Recursion and Tail Calls
Some problems are naturally defined in terms of smaller versions of themselves: a folder contains files and other folders; the DOM is a tree of elements containing elements; a JSON value may contain arrays and objects of JSON values. Recursion, a function calling itself, expresses these problems directly. It comes with a cost in JavaScript: every call uses stack space, and engines limit the stack, so deep recursion can crash with a RangeError. Knowing when recursion is appropriate and how to avoid the limit is part of writing robust code.
Two parts of every recursive function.
- A base case that answers directly, without recursion. Without it, the function never stops.
- A recursive case that reduces the problem and calls the function on the smaller problem.
function factorial(n) {
if (n <= 1) return 1; // base case
return n * factorial(n - 1); // recursive case: a smaller problem
}
console.log(factorial(5)); // 120
The call stack. Each call pushes a stack frame (its arguments, local variables and return address). A frame is popped when the call returns.
factorial(3) call stack (top at the top)
= 3 * factorial(2) +----------------------+
= 2 * factorial(1) | factorial(1) -> 1 | <- base case, returns first
= 1 +----------------------+
= 2 * 1 = 2 | factorial(2): 2 * ? |
= 3 * 2 = 6 +----------------------+
| factorial(3): 3 * ? |
+----------------------+
| global code |
+----------------------+
Recursion on trees. Recursion shines when the data itself is recursive. The depth of recursion is the depth of the tree, which for realistic documents and data is small.
// Sum all numbers in an arbitrarily nested structure
function deepSum(value) {
if (typeof value === "number") return value; // base case
if (Array.isArray(value)) return value.reduce((s, v) => s + deepSum(v), 0);
if (value && typeof value === "object") return deepSum(Object.values(value));
return 0; // anything else counts as 0
}
console.log(deepSum({ a: 1, b: [2, { c: 3 }], d: "x" })); // 6
// Walk a DOM subtree (Chapter 6)
function countElements(node) {
let count = 1;
for (const child of node.children) count += countElements(child);
return count;
}
Stack overflow. Engines limit the call stack, typically to roughly ten thousand frames, depending on the engine and on frame size. Exceeding it throws RangeError: Maximum call stack size exceeded.
const sumTo = (n) => (n === 0 ? 0 : n + sumTo(n - 1));
console.log(sumTo(1_000)); // 500500
// sumTo(1_000_000); // RangeError: Maximum call stack size exceeded
Tail calls. A call is in tail position when it is the very last thing a function does, so its result is returned directly with no further work. In factorial above, the recursive call is not a tail call, because the result must still be multiplied by n. An accumulator parameter makes it one:
"use strict";
function factorialTail(n, acc = 1) {
if (n <= 1) return acc;
return factorialTail(n - 1, n * acc); // tail call: nothing left to do after it returns
}
With proper tail calls (PTC), an engine can reuse the current stack frame for a tail call, so tail recursion runs in constant stack space. ES2015 specified PTC for strict-mode code, but in practice only Safari's JavaScriptCore implements it. V8 (Chrome, Edge, Node.js) and SpiderMonkey (Firefox) do not, mainly because of concerns about debugging (frames disappearing from stack traces) and performance. You therefore cannot rely on tail-call optimisation in portable JavaScript.
Alternatives for deep recursion.
| Technique | Idea | When to use |
|---|---|---|
| Loop | Rewrite the recursion as iteration with an accumulator | Linear recursion (lists, counters) |
| Explicit stack | Keep your own array of pending work instead of using the call stack | Deep trees and graphs (DFS) |
| Trampoline | Return a thunk instead of making the call; a loop runs the thunks | Keeping a recursive style without stack growth |
| Memoisation | Cache results of overlapping subproblems | Exponential recursion such as naive Fibonacci |
// Explicit stack: depth-first traversal that cannot overflow
function deepSumIterative(root) {
let total = 0;
const stack = [root];
while (stack.length) {
const value = stack.pop();
if (typeof value === "number") total += value;
else if (value && typeof value === "object") stack.push(...Object.values(value));
}
return total;
}
// Trampoline: each step returns either a result or a function for the next step
const trampoline = (fn) => (...args) => {
let result = fn(...args);
while (typeof result === "function") result = result(); // keep stepping in a loop
return result;
};
const sumToT = trampoline(function step(n, acc = 0) {
return n === 0 ? acc : () => step(n - 1, acc + n); // return a thunk instead of recursing
});
console.log(sumToT(1_000_000)); // 500000500000: no stack overflow
// Memoisation: naive fib(40) makes over 300 million calls; the cached version makes 79
const fib = (() => {
const memo = new Map();
return function f(n) {
if (n < 2) return n;
if (!memo.has(n)) memo.set(n, f(n - 1) + f(n - 2));
return memo.get(n);
};
})();
console.log(fib(90)); // 2880067194370816000 (exceeds 2^53, so not exact: use BigInt for exact results)
Key Takeaways
- A recursive function needs a base case and a recursive case that makes progress towards it.
- Each call adds a stack frame. Engines limit the stack, and exceeding it throws
RangeError: Maximum call stack size exceeded. - Recursion fits tree-shaped data (DOM, JSON, file systems), whose depth is usually small.
- Proper tail calls are in the specification but implemented only in Safari. Do not rely on them.
- For deep recursion, use loops, an explicit stack or a trampoline. For overlapping subproblems, memoise.
Practice
- (Beginner) Write a recursive
flatten(array)that flattens arbitrarily nested arrays, then compare it witharray.flat(Infinity).- (Intermediate) Write a recursive
deepEqual(a, b)for plain objects, arrays and primitives. Then rewrite it with an explicit stack and test both on a structure nested 20 000 levels deep.- (Interview) "What is a tail call, and does JavaScript optimise it?" Hint: define tail position, explain how frame reuse avoids stack growth, and describe the gap between the ES2015 specification and actual engine support.
Generators and Iterators
for...of, spread, destructuring and Array.from all work on arrays, strings, maps and sets, and they can work on your own objects too. They rely on a shared iteration protocol: a small contract that any object can implement to say "here is how to give me your values one at a time". Implementing the protocol by hand is verbose, so ES2015 also introduced generators: functions that can pause and resume, which make writing iterators easy. Generators also enable lazy sequences, which compute values only when they are requested, including infinite ones.
The iteration protocol. Two interfaces work together:
| Interface | Requirement |
|---|---|
| Iterable | Has a method [Symbol.iterator]() that returns an iterator |
| Iterator | Has a method next() that returns { value, done }. done: true means the sequence is finished. |
for (const x of iterable) { ... }
iterator = iterable[Symbol.iterator]()
loop:
{ value, done } = iterator.next()
if done -> stop
x = value; run the body
| Consumers of iterables | Built-in iterables |
|---|---|
for...of, spread [...it], array destructuring, Array.from, new Map(it), new Set(it), Promise.all(it), yield* | Array, String, Map, Set, TypedArray, arguments, NodeList, generator objects |
Implementing an iterator by hand.
class Range {
constructor(start, end, step = 1) {
Object.assign(this, { start, end, step });
}
[Symbol.iterator]() { // makes Range iterable
let current = this.start;
const { end, step } = this;
return { // the iterator object
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { value: undefined, done: true };
},
};
}
}
console.log([...new Range(1, 10, 3)]); // [1, 4, 7, 10]
for (const n of new Range(1, 3)) console.log(n); // 1 2 3
const [first, second] = new Range(5, 100); // destructuring only pulls two values: 5, 6
Generator functions. A generator function, written function*, returns a generator object when called; its body does not run yet. Each call to next() runs the body until the next yield, which pauses the function and hands out a value. The function's local variables are preserved between calls.
function* threeSteps() {
console.log("start");
yield 1; // pause here, hand out 1
console.log("resumed");
yield 2;
return "end"; // final value; done becomes true
}
const gen = threeSteps(); // nothing is logged yet
console.log(gen.next()); // logs "start", then { value: 1, done: false }
console.log(gen.next()); // logs "resumed", then { value: 2, done: false }
console.log(gen.next()); // { value: "end", done: true }
console.log(gen.next()); // { value: undefined, done: true }
console.log([...threeSteps()]); // [1, 2]: for...of and spread ignore the return value
caller generator body
gen.next() -------------> runs until yield 1
<------------- { value: 1, done: false } (paused, state kept)
gen.next() -------------> resumes after yield 1, runs until yield 2
<------------- { value: 2, done: false }
gen.next() -------------> resumes, reaches return "end"
<------------- { value: "end", done: true } (finished)
The Range iterator above becomes a few lines with a generator, because a generator object is both an iterator and an iterable:
class Range2 {
constructor(start, end, step = 1) { Object.assign(this, { start, end, step }); }
*[Symbol.iterator]() { // a generator method
for (let n = this.start; n <= this.end; n += this.step) yield n;
}
}
console.log([...new Range2(1, 10, 3)]); // [1, 4, 7, 10]
Lazy and infinite sequences. Because values are produced on demand, a generator can describe an infinite sequence; the consumer decides how many values to take.
function* naturals() {
let n = 1;
while (true) yield n++; // infinite, but only computes what is requested
}
function* take(iterable, count) {
if (count <= 0) return;
for (const value of iterable) {
yield value;
if (--count === 0) return; // returning closes the source iterator too
}
}
function* filter(iterable, predicate) {
for (const value of iterable) if (predicate(value)) yield value;
}
const firstFiveEven = [...take(filter(naturals(), (n) => n % 2 === 0), 5)];
console.log(firstFiveEven); // [2, 4, 6, 8, 10]
ES2025 added iterator helpers, methods on all built-in iterators that do the same lazily: naturals().filter((n) => n % 2 === 0).take(5).toArray(). Other helpers include map, drop, flatMap, reduce, some, every, find and Iterator.from().
Two-way communication and control.
| Feature | Effect |
|---|---|
yield* other | Delegates to another iterable, yielding each of its values |
gen.next(value) | Resumes the generator; value becomes the result of the paused yield expression (the first next argument is ignored) |
gen.return(value) | Finishes the generator early, running any finally blocks |
gen.throw(error) | Throws error at the paused yield, where the generator can catch it |
function* conversation() {
const name = yield "What is your name?"; // receives the value passed to the next next()
const lang = yield `Hi ${name}, favourite language?`;
return `${name} likes ${lang}`;
}
const chat = conversation();
console.log(chat.next().value); // "What is your name?"
console.log(chat.next("Ada").value); // "Hi Ada, favourite language?"
console.log(chat.next("JavaScript").value); // "Ada likes JavaScript"
function* tree(node) { // recursive traversal with yield*
yield node.name;
for (const child of node.children ?? []) yield* tree(child);
}
const fsTree = { name: "/", children: [{ name: "src", children: [{ name: "app.js" }] }, { name: "README" }] };
console.log([...tree(fsTree)]); // ["/", "src", "app.js", "README"]
Generators that receive values through next(value) were the basis of early async libraries such as co, which led to async/await. Async generators and for await...of are covered in Chapter 5.
Key Takeaways
- An iterable has
[Symbol.iterator](), which returns an iterator. An iterator hasnext(), which returns{ value, done }. for...of, spread, destructuring,Array.from,Map,SetandPromise.allall consume iterables.- A generator function (
function*) returns a paused generator object.yieldhands out a value and pauses;next()resumes. - Generators are lazy, so they can represent infinite sequences. Iterator helpers (ES2025) provide lazy
map,filterandtakeon iterators. yield*delegates to another iterable.next(value),return()andthrow()let the caller communicate with and control a generator.
Practice
- (Beginner) Write a generator
countdown(n)that yieldsndown to0, and use it withfor...ofand with spread.- (Intermediate) Make a
LinkedListclass iterable with a generator method, then implement lazymap(iterable, fn)andchunk(iterable, size)generators and use them on the list without creating intermediate arrays.- (Interview) "What is the difference between an iterable and an iterator, and why is a generator object both?" Hint: look at what
gen[Symbol.iterator]()returns, and consider what happens if you iterate the same generator object twice.
Currying and Function Composition
Functional programming builds complex behaviour by combining small, single-purpose functions. Two techniques make this combination practical. Currying turns a function of several arguments into a chain of functions of one argument each, so you can create specialised versions by supplying arguments one at a time. Composition chains functions so the output of one becomes the input of the next. Both are common in libraries (Ramda, lodash/fp, Redux middleware, RxJS pipes), and both build directly on closures and higher-order functions.
Currying versus partial application.
| Currying | Partial application | |
|---|---|---|
| Idea | Transform f(a, b, c) into f(a)(b)(c) | Fix some arguments now, supply the rest later |
| Result | A chain of unary functions | One function with fewer parameters |
| Example | add(1)(2)(3) | const add1 = add.bind(null, 1); add1(2, 3) |
// Manual currying with nested arrows: each call returns a function waiting for the next argument
const curriedAdd = (a) => (b) => (c) => a + b + c;
console.log(curriedAdd(1)(2)(3)); // 6
const addTen = curriedAdd(10); // a specialised function, thanks to the closure over a
console.log(addTen(5)(1)); // 16
// Practical example: configurable validators
const hasMinLength = (min) => (str) => str.length >= min;
const matches = (regex) => (str) => regex.test(str);
const isValidUsername = hasMinLength(3);
const isEmail = matches(/^[^@\s]+@[^@\s]+\.[^@\s]+$/);
console.log(isValidUsername("ad"), isEmail("ada@example.com")); // false true
A generic curry helper. It uses fn.length to know how many arguments to collect, and accepts them in any grouping.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args); // enough arguments: call the original
}
return (...more) => curried.apply(this, [...args, ...more]); // otherwise collect more
};
}
const volume = curry((l, w, h) => l * w * h);
console.log(volume(2)(3)(4)); // 24
console.log(volume(2, 3)(4)); // 24
console.log(volume(2)(3, 4)); // 24
Because fn.length ignores parameters with defaults and rest parameters (see Parameters, Default Values and arguments), a generic curry does not work reliably with them.
Function composition. In mathematics, (f ∘ g)(x) = f(g(x)). In code, composition lets you build a pipeline from small transformations, instead of nesting calls (f(g(h(x)))) or creating temporary variables.
compose(f, g, h)(x) = f(g(h(x))) applies right to left (mathematical order)
pipe(h, g, f)(x) = f(g(h(x))) applies left to right (reading order)
x --> [ h ] --> [ g ] --> [ f ] --> result
const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input);
const compose = (...fns) => (input) => fns.reduceRight((value, fn) => fn(value), input);
// Small, reusable, individually testable steps
const trim = (s) => s.trim();
const toLower = (s) => s.toLowerCase();
const collapseSpaces = (s) => s.replace(/\s+/g, "-");
const stripInvalid = (s) => s.replace(/[^a-z0-9-]/g, "");
const slugify = pipe(trim, toLower, collapseSpaces, stripInvalid);
console.log(slugify(" Hello World, JS! ")); // "hello-world-js"
Currying and composition together. Composition requires each step to take exactly one argument (the previous step's output). Currying turns multi-argument functions into such steps by fixing their configuration first; that is why curried functions usually take data last.
const map = (fn) => (array) => array.map(fn); // configuration first, data last
const filter = (pred) => (array) => array.filter(pred);
const sortBy = (key) => (array) => [...array].sort((a, b) => a[key] - b[key]);
const take = (n) => (array) => array.slice(0, n);
const topThreeCheapInStock = pipe(
filter((p) => p.stock > 0),
sortBy("price"),
take(3),
map((p) => p.name),
);
const products = [
{ name: "Pen", price: 2, stock: 10 },
{ name: "Lamp", price: 30, stock: 0 },
{ name: "Mug", price: 8, stock: 4 },
{ name: "Book", price: 15, stock: 1 },
{ name: "Clip", price: 1, stock: 99 },
];
console.log(topThreeCheapInStock(products)); // ["Clip", "Pen", "Mug"]
Writing functions without mentioning their data argument, as in topThreeCheapInStock, is called point-free style. A TC39 proposal for a pipeline operator (value |> f(%) |> g(%)) would add pipelines to the language itself, but it is not yet standard.
Trade-offs.
| Benefit | Cost |
|---|---|
| Small, reusable, easily tested functions | Deep chains of anonymous functions can be hard to debug (stack traces show many arrows) |
| Configuration and data cleanly separated | Point-free code can be cryptic to readers unfamiliar with the style |
| Easy to reorder or extend pipelines | Generic curry relies on fn.length, which defaults and rest parameters break |
| Encourages pure functions | Extra function calls and intermediate arrays (rarely significant) |
Use these techniques where they make code clearer, such as data-transformation pipelines and configurable helpers, rather than everywhere.
Key Takeaways
- Currying turns
f(a, b, c)intof(a)(b)(c). Partial application fixes some arguments and returns a function for the rest. - Both rely on closures to remember the arguments already supplied.
composeapplies functions right to left andpipeleft to right. Both can be written in one line withreduce.- Curried functions that take configuration first and data last plug directly into pipelines.
- Prefer clarity: use currying and composition for pipelines and reusable helpers, not as a goal in themselves.
Practice
- (Beginner) Write a curried
greet(greeting)(name)and use it to createsayHelloandsayCiao. Then writepipeand use it to buildshout = pipe(toUpper, addExclamation).- (Intermediate) Using
pipeand curried helpers, build a function that takes an array of log lines such as"2026-10-02 ERROR Disk full", keeps only errors, extracts the message, removes duplicates and returns them sorted. Each step must be a separate, testable function.- (Interview) "Implement a generic
curryfunction. What are its limitations?" Hint: usefn.lengthto decide when to call the original, and think about functions with default or rest parameters and about preservingthis.
<a id="34-errors-and-modules"></a>
3.4 Errors and Modules
This subchapter covers how JavaScript reports and handles failures, the stricter language mode that turns silent mistakes into errors, and the module systems and build tools used to split programs into files and ship them efficiently.
The Error Object and Custom Errors
When something goes wrong, the code that detects the problem is rarely the code that knows how to respond. A function deep in a parser notices malformed input, but the user interface several layers up decides whether to show a message, retry or give up. Error objects carry the information across those layers: what went wrong (a message), what kind of problem it was (a type), where it happened (a stack trace), and optionally what caused it. Well-designed error types let calling code react to specific failures instead of guessing from message strings.
Anatomy of an Error.
| Property | Content | Standard? |
|---|---|---|
message | Human-readable description | Yes |
name | Error type, such as "TypeError" (inherited from the prototype) | Yes |
cause | The underlying error or value that triggered this one (ES2022) | Yes |
stack | Stack trace: the chain of calls at the moment the error was created | De facto (every engine supports it, format varies) |
const err = new Error("Configuration file not found", { cause: { path: "/etc/app.json" } });
console.log(err.name); // "Error"
console.log(err.message); // "Configuration file not found"
console.log(err.cause); // { path: "/etc/app.json" }
console.log(err.stack);
// Error: Configuration file not found
// at loadConfig (config.js:12:15) <- where the error object was created
// at main (index.js:4:3)
The stack trace is captured when the Error is created, not when it is thrown.
Built-in error types.
| Type | Thrown when | Example |
|---|---|---|
Error | Generic base type | throw new Error("...") |
TypeError | A value has the wrong type for an operation | null.x, undefined(), 1n + 1, assigning to a const |
RangeError | A value is outside the allowed range | new Array(-1), (1).toFixed(101), stack overflow |
ReferenceError | An identifier cannot be resolved | Using an undeclared variable, TDZ access |
SyntaxError | Code or data cannot be parsed | JSON.parse("{bad}"), eval("1 +") |
URIError | Malformed URI escape sequences | decodeURIComponent("%") |
EvalError | Historical, not thrown by modern engines | |
AggregateError | Several errors are reported together | Promise.any when all promises reject (Chapter 5) |
Browser and Node.js APIs add their own types, such as DOMException (for example, an AbortError from fetch, Chapter 5) and Node's system errors with a code such as "ENOENT".
Always throw Error objects. JavaScript lets you throw any value (throw "oops"), but a string has no stack trace, no type and no cause, and code that expects err.message breaks. Linters enforce this with the no-throw-literal rule.
Custom error classes. Extend Error to create types that callers can recognise with instanceof and that carry extra, structured data.
class AppError extends Error {
constructor(message, options) {
super(message, options); // passes message and { cause } to Error
this.name = this.constructor.name; // "ValidationError", "NotFoundError", ...
}
}
class ValidationError extends AppError {
constructor(field, message, options) {
super(message, options);
this.field = field; // extra data for the caller
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} ${id} not found`);
this.resource = resource;
this.id = id;
this.status = 404; // useful when mapping errors to HTTP responses (Chapter 12)
}
}
function validateAge(age) {
if (!Number.isInteger(age) || age < 0) {
throw new ValidationError("age", `Invalid age: ${age}`);
}
return age;
}
try {
validateAge(-3);
} catch (err) {
console.log(err instanceof ValidationError); // true
console.log(err instanceof AppError); // true
console.log(err instanceof Error); // true
console.log(err.name, err.field); // "ValidationError" "age"
}
Error
+-- AppError (your application's base type)
+-- ValidationError field
+-- NotFoundError resource, id, status
+-- AuthError ...
A shared base class lets you distinguish "errors we expected and modelled" from unexpected bugs (TypeError, ReferenceError) in a single instanceof AppError check. Minifiers may rename classes, which changes this.constructor.name; if the name matters (for logging or serialisation), set it explicitly as a string.
Error chaining with cause. When you catch a low-level error and throw a more meaningful one, pass the original as cause so the root problem is not lost.
async function loadUserProfile(id) {
try {
const res = await fetch(`/api/users/${id}`);
return await res.json();
} catch (err) {
throw new AppError(`Could not load profile for user ${id}`, { cause: err }); // wrap, don't discard
}
}
// Later: console.error(e.message, "caused by", e.cause);
Key Takeaways
- An
Errorhas amessage, aname, an optionalcauseand astacktrace captured at creation time. - Built-in types (
TypeError,RangeError,ReferenceError,SyntaxError, ...) describe common failure categories. - Always throw
Errorinstances, never strings or plain objects. - Extend
Errorto create custom types with extra fields. A common application base class makes expected errors easy to separate from bugs. - When wrapping an error, pass the original as
{ cause }to preserve the full chain.
Practice
- (Beginner) Trigger each built-in error type at least once in the console (except
EvalError) and record thenameandmessageof each.- (Intermediate) Create an
HttpErrorclass withstatusandurlfields, and afetchJson(url)wrapper that throws it for non-2xx responses and wraps network failures withcause. Log the full cause chain in a single function.- (Interview) "Why should you throw
Errorobjects rather than strings, and how would you design an error hierarchy for a web application?" Hint: consider stack traces,instanceof, extra structured fields, and how a top-level handler decides what to show the user.
try, catch, finally and throw
An error that nobody handles stops the current task and, in the browser, prints a red message to the console. Sometimes that is the right outcome. Often, though, a program can recover: show a friendly message, use a fallback value, retry, or at least release resources and log the problem. The throw statement signals an error, and the try...catch...finally statement defines where errors are handled and what must happen regardless of the outcome.
throw. throw expression stops the current function immediately and unwinds the call stack: each function is exited in turn until a surrounding try block with a catch is found. If none is found, the error becomes an uncaught exception.
main() <- has try/catch: the error is caught here
+-- loadSettings() <- no try: exited
+-- parseFile() <- no try: exited
+-- throw new SyntaxError("Unexpected token")
try...catch...finally.
function readSettings(json) {
let settings;
try {
settings = JSON.parse(json); // may throw a SyntaxError
console.log("parsed"); // skipped if the line above throws
} catch (err) {
console.warn("Invalid settings, using defaults:", err.message);
settings = { theme: "light" }; // recover with a fallback
} finally {
console.log("done reading settings"); // always runs: success, error or return
}
return settings;
}
readSettings('{"theme":"dark"}'); // "parsed", "done reading settings"
readSettings("{oops}"); // warning, "done reading settings"
+-----------+ no error +-----------+
------> | try | -----------> | finally | ------> continue
+-----------+ +-----------+
| error thrown ^
v |
+-----------+ |
| catch | -------------------+
+-----------+ (if catch throws, finally still runs,
then the new error propagates)
| Form | Meaning |
|---|---|
try { } catch (err) { } | Handle errors |
try { } catch { } | Optional catch binding (ES2019), when the error object is not needed |
try { } finally { } | No handling, but guaranteed clean-up; the error continues to propagate |
try { } catch (err) { } finally { } | Both |
Catch only what you can handle. A catch block catches everything thrown in its try block, including bugs such as typos (ReferenceError) that you never meant to handle. Check the error type and rethrow anything you cannot deal with.
function getUser(id) {
try {
return repository.find(id);
} catch (err) {
if (err instanceof NotFoundError) { // the expected, recoverable case
return null;
}
throw err; // everything else: let it propagate
}
}
Never write an empty catch {} that silently swallows errors. If ignoring an error is truly correct, add a comment explaining why.
finally for clean-up. finally is the place for actions that must happen no matter what: hiding a loading spinner, releasing a lock, closing a file handle.
async function save(form) {
setLoading(true);
try {
await api.save(form); // try/catch works with await (Chapter 5)
showToast("Saved");
} catch (err) {
showToast("Save failed, please retry");
reportError(err);
} finally {
setLoading(false); // runs after success and after failure
}
}
finally and return. finally runs even when try or catch executes return. If finally itself contains a return (or throw), it overrides the earlier result, and can even swallow an exception. Never return from finally.
function surprising() {
try {
throw new Error("lost");
} finally {
return "finally wins"; // the error is silently discarded
}
}
console.log(surprising()); // "finally wins"
Limits of try...catch. It only catches errors thrown synchronously while its block is executing, or errors from promises it awaits. Errors thrown later, in callbacks scheduled by timers or events, run in a different call stack and escape it:
try {
setTimeout(() => {
throw new Error("too late"); // NOT caught: runs after the try block has finished
}, 0);
} catch (err) {
console.log("never reached");
}
Chapter 5 covers error handling for callbacks, promises and async/await in detail.
Global safety nets. For errors that escape all handlers, register global listeners to log them (for example, to an error-monitoring service). They are a last resort for reporting, not a substitute for local handling.
| Environment | Uncaught synchronous errors | Unhandled promise rejections |
|---|---|---|
| Browser | window.addEventListener("error", ...) | window.addEventListener("unhandledrejection", ...) |
| Node.js | process.on("uncaughtException", ...) | process.on("unhandledRejection", ...) |
Errors versus return values. Use exceptions for exceptional conditions that the immediate caller is unlikely to handle (corrupted data, network failure, programmer errors). For expected outcomes such as "no search results" or "invalid form field", returning a value (null, an empty array, or a { ok, error } result object) is often clearer than throwing.
Key Takeaways
throwstops execution and unwinds the stack to the nearest enclosingcatch. If there is none, the error is uncaught.catchhandles errors from itstryblock. Check the error type and rethrow what you cannot handle; never swallow errors silently.finallyalways runs and is the place for clean-up. Areturninfinallyoverrides results and discards exceptions.try...catchdoes not catch errors thrown later in timer or event callbacks. Withawait, it does catch promise rejections.- Global
errorandunhandledrejectionhandlers are for reporting, not for normal error handling.
Practice
- (Beginner) Write
safeJsonParse(text, fallback)that returns the parsed value orfallbackwhen the text is invalid, and logs a warning with the error message.- (Intermediate) Write
withRetry(fn, attempts)that calls a synchronous function and retries it when it throws a customTransientError, rethrowing any other error immediately and throwing anAggregateErrorwith all collected errors after the last attempt.- (Interview) "What does
function f() { try { return 'try'; } finally { console.log('finally'); } }log and return, and what changes iffinallyalso returns a value?" Hint: thereturnvalue oftryis computed first, thenfinallyruns; consider which completion wins.
Strict Mode
JavaScript was designed in ten days in 1995, and some early decisions turned mistakes into silent behaviour: assigning to a misspelled variable created a new global, writing to a read-only property did nothing, and this in a plain function call was the global object. Fixing these rules for everyone would have broken existing websites. ES5 (2009) introduced strict mode instead: an opt-in variant of the language that throws errors for these mistakes, removes some confusing features and makes code easier for engines to optimise.
Enabling strict mode. Put the directive "use strict"; as the first statement of a script or a function. Strict mode is also automatically enabled inside ES modules and inside class bodies, so most modern code is strict without the directive.
"use strict"; // whole script (must be the first statement)
function legacy() {
// sloppy unless the file is strict
}
function modern() {
"use strict"; // just this function
}
| Code | Strict? |
|---|---|
Classic <script> without the directive | No ("sloppy mode") |
Script or function starting with "use strict"; | Yes |
ES modules (<script type="module">, .mjs, import/export files) | Always |
class bodies | Always |
| Node.js CommonJS files | No, unless the directive is present |
What changes.
| Behaviour | Sloppy mode | Strict mode |
|---|---|---|
| Assigning to an undeclared variable | Creates a global variable | ReferenceError |
| Writing to a read-only property or a getter-only property | Silently ignored | TypeError |
| Adding a property to a frozen or non-extensible object | Silently ignored | TypeError |
Deleting a non-configurable property (delete Object.prototype) | Returns false | TypeError |
Setting a property on a primitive ("s".x = 1) | Silently ignored | TypeError |
this in a plain function call f() | The global object | undefined |
thisArg of call/apply/bind when null or a primitive | Replaced by the global object / boxed | Passed through unchanged |
Duplicate parameter names function f(a, a) | Allowed | SyntaxError |
Legacy octal literals 010 and escapes "\010" | Allowed | SyntaxError (use 0o10) |
with statement | Allowed | SyntaxError |
delete identifier (deleting a variable) | Allowed | SyntaxError |
arguments linked to named parameters | Yes | No |
arguments.callee | Allowed | TypeError |
eval declaring variables | Leaks into the surrounding scope | Stays inside the eval |
| Function declarations in blocks | Partly function-scoped (legacy rules) | Block-scoped |
Future reserved words (implements, interface, package, private, protected, public, static, let, yield) as identifiers | Allowed | SyntaxError |
Example: errors that strict mode exposes
"use strict";
let total = 0;
function addToTotal(n) {
totl = total + n; // typo: ReferenceError instead of a silent new global
}
const point = Object.freeze({ x: 1 });
point.x = 2; // TypeError: Cannot assign to read only property 'x'
function whoAmI() {
return this;
}
console.log(whoAmI()); // undefined (sloppy mode would return the global object)
const user = {
name: "Ada",
greet() { return this.name; },
};
const detached = user.greet;
detached(); // TypeError: Cannot read properties of undefined (reading 'name')
// In sloppy mode: reads window.name and returns "" - a silent bug
Pitfalls.
- The directive must be the first statement. Anywhere else, it is just an unused string literal.
- A function with non-simple parameters (defaults, rest or destructuring) cannot contain
"use strict";; that is aSyntaxError. Make the whole file strict instead. - Concatenating a strict file with a sloppy one (as old build tools did) can switch the mode for the entire bundle. Modules and modern bundlers avoid this problem.
Strict mode is no longer something you think about much, because modules and classes are strict by default. It still matters when you maintain classic scripts and CommonJS code, and it explains why this is undefined in detached methods.
Key Takeaways
- Strict mode turns silent mistakes into errors and removes confusing legacy features.
- Enable it with
"use strict";as the first statement of a script or function. ES modules and class bodies are always strict. - The most visible changes: undeclared assignments throw, writes to read-only properties throw, and
thisisundefinedin plain function calls. with, legacy octal literals, duplicate parameters andarguments.calleeare forbidden.- Functions with default, rest or destructured parameters cannot contain the directive themselves.
Practice
- (Beginner) Run the same five-line script (an undeclared assignment, a write to a frozen object, a plain call that returns
this) once without and once with"use strict";, and record the differences.- (Intermediate) You inherit a sloppy-mode library that relies on
thisbeingwindowin plain calls and onargumentsaliasing. List the changes needed before converting it into an ES module, and explain how you would find them (linters, tests, search patterns).- (Interview) "What is strict mode, and is it still relevant if you write ES modules?" Hint: mention which code is strict automatically, and which environments (classic scripts, CommonJS) still default to sloppy mode.
CommonJS Modules
For its first fifteen years, JavaScript had no way to split a program into files with explicit dependencies. Browsers loaded scripts that all shared one global scope. When Node.js appeared in 2009, server-side programs needed real modules, so Node adopted CommonJS (CJS), a community specification based on two ideas: a require() function that loads a module synchronously, and a module.exports object that defines what a module exposes. CommonJS powered the npm ecosystem for a decade, and a large part of npm still uses it, so you need to read and use it even though ES modules are now the standard.
Exporting and importing.
// math.js
const PI = 3.14159;
function area(r) {
return PI * r * r;
}
function circumference(r) {
return 2 * PI * r;
}
module.exports = { area, circumference }; // the public API: whatever module.exports holds
// app.js
const { area } = require("./math"); // relative path: the .js extension is optional
const math = require("./math"); // the same cached object
const fs = require("node:fs"); // a built-in module
const express = require("express"); // a package from node_modules
console.log(area(2), math.circumference(1));
The module wrapper. Node.js does not run a CommonJS file directly. It wraps the file's code in a function, which is why each module has its own scope and why require, module, exports, __filename and __dirname look like globals but are really parameters:
(function (exports, require, module, __filename, __dirname) {
// ... your module code ...
});
exports versus module.exports. exports is a shortcut variable that initially points to the same object as module.exports. require returns module.exports, so adding properties to exports works, but reassigning exports breaks the link.
exports.a = 1; // works: adds to the shared object
module.exports.b = 2; // works
exports = { c: 3 }; // BROKEN: rebinds the local variable only; require() never sees it
module.exports = function () {}; // works: replaces the exported value entirely (e.g. a single function)
Initially: exports --------+
+--> { } <- module.exports, returned by require()
module.exports -+
After exports = { c: 3 }:
exports --------> { c: 3 } (ignored)
module.exports -> { } (still what require() returns)
How require works.
- Resolve the specifier to a file: built-in modules (
node:fs) first; paths starting with./,../or/relative to the current file, trying.js,.json,.nodeand directoryindex.jsfiles; otherwise searchnode_modulesfolders from the current directory upwards, using each package'spackage.json("exports"or"main"). - Load and run the module synchronously, once.
- Cache the result. Every later
requireof the same resolved file returns the samemodule.exportsobject, so modules behave like singletons.
Exports are values, not live bindings. require returns whatever object module.exports holds. If a module exports a primitive and later changes its own variable, importers do not see the change, because they received a copy of the value.
// counter.js
let count = 0;
function increment() { count++; }
module.exports = { count, increment }; // copies the current value of count (0)
// app.js
const counter = require("./counter");
counter.increment();
console.log(counter.count); // 0: the exported property never changes
ES modules behave differently: their imports are live bindings (see ES Modules with import and export).
Circular dependencies. If a.js requires b.js and b.js requires a.js, Node returns the partially filled module.exports of a.js to b.js (whatever had been exported before the require call). This often produces undefined functions at startup. Restructure the code to remove the cycle, or move the require inside the function that needs it.
Characteristics and limitations.
| Characteristic | Consequence |
|---|---|
| Synchronous loading | Simple on servers with a local disk; unsuitable for browsers, where loading means network requests |
require is an ordinary function call | Can appear anywhere, with computed paths, so tools cannot reliably analyse dependencies statically |
| Not supported natively by browsers | Browser code written in CommonJS must be bundled (see Module Bundling and Tree Shaking) |
| Dynamic structure | Makes tree shaking much less effective |
CommonJS and ES modules together. Node.js decides how to load a .js file from the nearest package.json: "type": "module" means ES modules, otherwise CommonJS. The .mjs and .cjs extensions force one or the other. ES modules can import CommonJS modules (the default import is module.exports), and recent Node.js versions (20.19+ and 22.12+) can also require() ES modules that do not use top-level await. Chapter 11 covers module interoperability in Node.js in detail.
Key Takeaways
- CommonJS uses
require()to load modules synchronously andmodule.exportsto define a module's public API. - Each module runs once inside a wrapper function and is cached; later
requirecalls return the same exported object. requirereturnsmodule.exports. Adding toexportsworks, reassigningexportsdoes not.- Exports are values: importers do not see later reassignments of the module's internal variables.
- CommonJS is dynamic and synchronous, which suits servers but not browsers and limits static analysis. New code should prefer ES modules.
Practice
- (Beginner) Create a
stringUtils.jsCommonJS module exportingcapitalizeandslugify, and use it fromindex.jswith destructuringrequire. Run it with Node.- (Intermediate) Create two modules that
requireeach other and log their exports at load time. Observe the partially filled exports, then fix the cycle in two ways: by moving the shared code into a third module, and by moving onerequireinside a function.- (Interview) "What is the difference between
exportsandmodule.exports?" Hint: draw both variables pointing to the same object, and show what happens to each when one of them is reassigned.
ES Modules with import and export
ES2015 finally gave JavaScript a standard module system, designed to work both in browsers and on servers: ECMAScript modules (ESM). Unlike CommonJS, ES module imports and exports are static declarations that the engine reads before running any code. That design enables asynchronous loading over the network, early detection of missing exports, live bindings between modules, and tree shaking by build tools. ES modules are now supported by all browsers and by Node.js, and they are the format to use for new code.
Named exports. A module can export any number of named bindings, either inline or in a list at the end.
// geometry.js
export const PI = 3.14159; // inline named export
export function area(r) {
return PI * r * r;
}
function perimeter(r) { return 2 * PI * r; }
class Shape {}
export { perimeter, Shape as BaseShape }; // export list, with renaming
Default export. A module can also have one default export, typically its main value.
// logger.js
export default class Logger { // the default export (can be anonymous)
log(msg) { console.log(`[log] ${msg}`); }
}
export const LEVELS = ["debug", "info", "warn"]; // named exports can coexist with a default
Importing.
import Logger, { LEVELS } from "./logger.js"; // default + named
import { area, perimeter as circ } from "./geometry.js"; // renaming on import
import * as geo from "./geometry.js"; // namespace object: geo.area, geo.PI, ...
import "./polyfills.js"; // side effects only: run the module, import nothing
console.log(area(1), circ(1), geo.PI);
new Logger().log(LEVELS.join(","));
Re-exporting (aggregating modules). A module can re-export from others, which is how libraries expose a single entry point (often an index.js, sometimes called a "barrel" file).
// shapes/index.js
export { area, perimeter } from "./geometry.js"; // re-export selected names
export * from "./polygons.js"; // re-export all named exports (not the default)
export { default as Logger } from "./logger.js"; // re-export a default under a name
export * as colors from "./colors.js"; // re-export as a namespace (ES2020)
| Syntax | Purpose |
|---|---|
export const x = ... / export function f() {} | Inline named export |
export { a, b as c } | Export list, with renaming |
export default expression | The module's default export |
import d from "m" | Import the default |
import { a, b as c } from "m" | Import named exports |
import * as ns from "m" | Import all named exports as a namespace object |
import "m" | Run the module for its side effects |
export ... from "m" | Re-export without importing locally |
import("m") | Dynamic import: returns a promise for the namespace |
Static structure. import and export declarations must appear at the top level of a module (not inside if blocks or functions), and specifiers must be string literals. The engine therefore knows the whole dependency graph before running any code, and loads modules in three phases:
1. Construction fetch and parse every module in the graph (in parallel over the network)
2. Linking connect each import to the matching export; missing names -> SyntaxError
3. Evaluation run each module's code once, dependencies first
Imports are hoisted: they are available throughout the module, even above the import line.
Live bindings. An import is not a copy of a value; it is a read-only view of the exporting module's variable. When the exporting module changes the variable, every importer sees the new value. Importers cannot assign to imported bindings.
// counter.js
export let count = 0;
export function increment() { count++; }
// app.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1: a live binding, unlike CommonJS
// count = 10; // TypeError: Assignment to constant variable.
Module scope and behaviour.
- Each module has its own top-level scope: top-level declarations are not globals.
- Modules are always in strict mode, and top-level
thisisundefined. - A module is evaluated once per URL (or resolved path), no matter how many times it is imported.
import.metaprovides module metadata, such asimport.meta.url(the module's URL) andimport.meta.resolve(specifier).
Dynamic import(). When you need to load a module conditionally or later (for example, a heavy chart library only when the user opens a dashboard), use the import() expression. It returns a promise for the module namespace and is the basis of code splitting in bundlers.
button.addEventListener("click", async () => {
const { default: Chart } = await import("./chart.js"); // loaded only on first click
new Chart(canvas).render(data);
});
const locale = navigator.language.startsWith("it") ? "it" : "en";
const messages = await import(`./i18n/${locale}.js`); // computed specifier: only possible dynamically
Top-level await (ES2022). In a module, await can be used outside any function. Modules that import it wait until it has finished evaluating.
// config.js
const response = await fetch("/config.json");
export const config = await response.json(); // importers receive a fully loaded config
Use it sparingly: a slow top-level await delays every module that depends on it.
Import attributes (ES2025). Non-JavaScript modules are imported with an attribute that states their type, which prevents a server from substituting executable code for data:
import settings from "./settings.json" with { type: "json" };
ES modules in the browser.
<script type="module" src="/js/main.js"></script> <!-- deferred by default, strict, own scope -->
<script nomodule src="/js/legacy-bundle.js"></script> <!-- ignored by browsers that support modules -->
<script type="importmap">
{ "imports": { "lodash-es": "https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js" } }
</script>
<script type="module">
import { debounce } from "lodash-es"; // bare specifier resolved by the import map
</script>
| Rule in browsers | Reason |
|---|---|
Specifiers must be URLs or relative paths with the extension (./utils.js) | There is no file-system resolution algorithm |
Bare specifiers ("lodash-es") need an import map or a bundler | The browser does not know where npm packages live |
| Module scripts are fetched with CORS | Cross-origin modules need the right Access-Control-Allow-Origin header |
Pages opened as file:// cannot load modules | Use a local development server |
CommonJS versus ES modules.
| CommonJS | ES modules | |
|---|---|---|
| Syntax | require(), module.exports | import, export |
| Structure | Dynamic: require anywhere, computed paths | Static declarations at top level (plus dynamic import()) |
| Loading | Synchronous | Asynchronous (fetch, link, evaluate) |
| Bindings | Copies of exported values | Live, read-only bindings |
| Strict mode | Opt-in | Always |
Top-level this | module.exports | undefined |
Top-level await | No | Yes |
| File-path helpers | __filename, __dirname | import.meta.url, import.meta.dirname (Node.js) |
| Browsers | Only through bundlers | Native |
| Tree shaking | Limited | Effective |
Named or default exports? Named exports are explicit, keep the same name in every importing file, support editor auto-imports and refactoring, and tree-shake well. Default exports are convenient for modules with a single main value (a React component per file, a configuration object). Many style guides prefer named exports for consistency; whatever you choose, be consistent across the codebase.
Key Takeaways
- ES modules use static
importandexportdeclarations, analysed before execution, which enables early errors, parallel loading and tree shaking. - A module can have many named exports and one default export. Re-exports aggregate modules into a single entry point.
- Imports are live, read-only bindings to the exporter's variables, unlike CommonJS's copied values.
- Modules have their own scope, are strict, run once, and support top-level
await. Dynamicimport()loads modules on demand and enables code splitting. - In browsers, use
<script type="module">, full relative paths with extensions, and import maps or a bundler for bare package names.
Practice
- (Beginner) Split a single-file to-do list script into three modules (
storage.js,render.js,main.js) with named exports, load it with<script type="module">from a local development server, and check the network panel to see each module being requested.- (Intermediate) Demonstrate live bindings: export a
letvariable and a function that changes it, import both in two different modules and show that both see the change. Repeat the experiment with CommonJS and explain the different output.- (Interview) "Why are ES module imports static, and what does that enable that CommonJS cannot do?" Hint: think about what a tool or browser can know about the dependency graph before executing any code.
Module Bundling and Tree Shaking
Modern applications are built from hundreds or thousands of modules: your own files plus many npm packages, written in TypeScript or JSX, importing CSS and images. Browsers can load ES modules natively, but shipping the source as-is is slow and often impossible: bare package names do not resolve, TypeScript and JSX do not run in the browser, and deep import chains cause network waterfalls. A bundler takes the module graph and produces a small number of optimised files for production. One of its key optimisations, tree shaking, removes code that is never used.
What a bundler does.
src/main.js (entry)
/ | \
src/app.jsx src/api.ts node_modules/date-fns
/ \ (hundreds of functions)
src/Button.jsx src/styles.css
|
v
1. Resolve follow every import from the entry point(s) to build the module graph
2. Transform TypeScript/JSX -> JavaScript, modern syntax -> target syntax, CSS processing
3. Link concatenate modules into chunks, rewriting imports into local references
4. Optimise tree shaking, minification, scope hoisting, constant folding
5. Split separate chunks for dynamic import() points and shared vendor code
6. Emit dist/assets/index-3f9a1c.js, dist/assets/dashboard-81bd2e.js,
dist/assets/index-a1c2.css, source maps, and an updated index.html
| Problem with unbundled source | How the bundler solves it |
|---|---|
Bare specifiers (import React from "react") | Resolves them in node_modules |
| npm packages published as CommonJS | Converts them into a format the bundle can use |
| TypeScript, JSX, Sass, Vue/Svelte single-file components | Transforms them through loaders and plugins |
| Deep import chains: each module is discovered only after its parent is downloaded | Merges modules so the browser gets the whole chunk at once |
| Unused code and long identifiers | Tree shaking and minification |
| Browser caching | Content hashes in file names (index-3f9a1c.js) allow long-lived caching |
Common tools.
| Tool | Notes |
|---|---|
| Vite | The default for new projects. Serves native ES modules in development (instant start-up, fast hot module replacement) and bundles for production. Used by Vue, Svelte, many React set-ups and frameworks such as SvelteKit and Astro. |
| webpack | The long-time standard. Very configurable through loaders and plugins; still widespread in existing projects. |
| Rollup | Focused on ES modules and libraries; produced clean output and popularised tree shaking. |
| esbuild | Extremely fast bundler and transformer written in Go; often used inside other tools. |
| Rspack, Rolldown, Turbopack | Newer Rust-based bundlers aiming at webpack- or Rollup-compatible APIs with much faster builds. |
| Parcel | Zero-configuration bundler. |
A minimal set-up with Vite.
npm create vite@latest my-app -- --template vanilla # scaffold a project
cd my-app && npm install
npm run dev # development server with native ESM and hot module replacement
npm run build # production bundle in dist/
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
build: {
target: "es2022", // syntax level of the output (see Browserslist, Chapter 1)
sourcemap: true, // map minified code back to the original source for debugging
},
});
Tree shaking. The name comes from the image of shaking a tree so the dead leaves fall off. Because ES module imports and exports are static, the bundler can see exactly which exports each module uses. Exports that nothing imports, and code reachable only from them, are removed from the bundle. (Technically, tree shaking marks live code starting from the entry points, and dead-code elimination in the minifier removes the rest.)
// utils.js
export function formatDate(d) { return d.toISOString().slice(0, 10); }
export function formatMoney(n) { return n.toFixed(2); }
export function heavyReport() { /* 50 KB of code */ }
// main.js
import { formatDate } from "./utils.js";
console.log(formatDate(new Date()));
// dist/index-[hash].js after tree shaking and minification (conceptually)
function o(t){return t.toISOString().slice(0,10)}console.log(o(new Date));
// formatMoney and heavyReport are gone
What prevents tree shaking.
| Obstacle | Why it blocks removal | Fix |
|---|---|---|
| CommonJS modules | require and module.exports are dynamic, so the bundler cannot prove an export is unused | Prefer packages that ship ESM (lodash-es rather than lodash) |
| Side effects at module top level | Code that modifies globals or registers things must run even if nothing is imported | Mark packages side-effect-free in package.json |
| Namespace objects used dynamically | utils[name]() might use any export | Import specific names |
| Large exported objects or classes | Unused methods on a used object cannot be removed | Export separate functions |
| Barrel files re-exporting everything | Can force bundlers to process (and sometimes keep) unrelated modules | Import from specific files, or ensure sideEffects: false |
The sideEffects flag. A module has side effects if importing it does something beyond defining exports (patching Array.prototype, injecting CSS, registering a custom element). Bundlers must keep such modules even if no export is used. Package authors declare which files are safe to drop:
{
"name": "my-ui-library",
"type": "module",
"sideEffects": ["*.css", "./src/polyfills.js"],
"exports": { ".": "./dist/index.js" }
}
"sideEffects": false declares that no file has side effects. Code generators and libraries also mark individual calls as removable when their result is unused with a /*#__PURE__*/ annotation, for example const x = /*#__PURE__*/ createStore();.
Code splitting. Every dynamic import() becomes a split point: the imported module and its dependencies go into a separate chunk that is downloaded only when needed. Bundlers also extract code shared by several chunks into common chunks. Route-based splitting (one chunk per page of a single-page application, Chapter 14) is the most common strategy.
Development versus production builds.
| Development | Production | |
|---|---|---|
| Goal | Fast feedback | Smallest, fastest output |
| Minification | No | Yes |
| Tree shaking | Usually not | Yes |
| Source maps | Detailed, inline | Separate files, or none in public builds |
| Extras | Hot module replacement, readable error overlays | Content hashing, compression, chunk splitting |
process.env.NODE_ENV / import.meta.env.MODE | "development" | "production" (libraries remove debug-only code) |
Measuring the result. Use a bundle analyser (rollup-plugin-visualizer, webpack-bundle-analyzer, or npx vite-bundle-visualizer) to see what ends up in each chunk, and check the network panel (Chapter 1) for the real transfer sizes.
Key Takeaways
- Bundlers build a module graph from entry points, transform non-standard code, combine modules into chunks, optimise them and emit hashed files for production.
- Vite is the common default today; webpack, Rollup, esbuild and newer Rust-based tools fill other niches.
- Tree shaking removes exports that are never imported. It depends on the static structure of ES modules.
- CommonJS, top-level side effects and dynamic namespace access prevent tree shaking.
"sideEffects"inpackage.jsonand/*#__PURE__*/annotations help bundlers remove code safely. - Dynamic
import()creates split points, so code is downloaded only when needed.
Practice
- (Beginner) Scaffold a Vite project, import one function from a module that exports five, run
npm run build, and search the output file to confirm that the unused functions are absent.- (Intermediate) Compare bundle sizes for
import { debounce } from "lodash"andimport { debounce } from "lodash-es"using a bundle analyser. Then move a rarely used feature behind a dynamicimport()and verify in the network panel that its chunk loads only on demand.- (Interview) "What is tree shaking, why does it work with ES modules but poorly with CommonJS, and what does the
sideEffectsfield inpackage.jsondo?" Hint: connect staticimport/exportto what a bundler can prove at build time, and explain why a module with side effects cannot be dropped even if none of its exports are used.
<a id="4-objects-arrays-and-object-oriented-javascript"></a>