Bindings, operators, control flow
Declarations and TDZ, literals, operators, short-circuiting, control flow, truthiness, ASI, and strict mode.
High-level overview
This page explains how JavaScript interprets a basic line of code: how a name gets a value, how the parser classifies the text, how operators group the text, and which expressions or statements actually run.
For each example, resolve those questions in that order. A surprising result usually comes from one of four causes: a name is unavailable, the parser chose a different structure, an operator grouped the expression differently, or control flow skipped part of the code.
Things to focus on
- Declarations and assignments are different actions.
let xcreates the binding namedx;x = valuechanges the value in that binding.constprevents reassignment ofx; it does not make an object stored inximmutable. - Scope and the temporal dead zone control name
access. A
letorconstbinding cannot be read before its declaration executes. Avarbinding can be read before its declaration executes, and that early read producesundefined. - Expressions and statements have different grammar.
An expression produces a value; a statement directs execution. The
parser can read
{}as a block or as an object literal depending on where the braces appear. - Precedence chooses grouping; short-circuiting chooses
execution. Operator precedence and associativity build the
expression tree.
&&,||,??, and?.can prevent JavaScript from evaluating code on their right. - Logical operators use different tests.
&&and||test truthiness and return one operand.??reacts only tonullandundefined. Optional chaining such asa?.breturnsundefinedwhenaisnullorundefined. - Control-flow keywords attach by grammar.
elseattaches to anif;breakandcontinuetarget an enclosing loop orswitch. Indentation does not change those targets. - Line breaks and strict mode change behavior. Automatic semicolon insertion can continue a statement across a newline. Strict mode turns several older, silent JavaScript behaviors into errors.
// 01 — BINDINGS, LITERALS, OPERATORS, CONTROL FLOW
//
// A reading file, and pure ECMAScript. Read it straight down: the comment above
// each construct gives its NAME, and `// =>` gives what the expression on its
// left evaluates to, so nothing is left hanging. Each section uses only what the
// lines just above it introduced.
//
// It still runs (`node 01-bindings-operators-control-flow.ts`) and prints
// nothing; the `// =>` annotations are the output, already collected.
//
// Mark anything you can't explain out loud with // ?
// ─── 1. DECLARATIONS ─────────────────────────────────────────────────────────
//
// Start here, because everything else leans on it: a NAME and the VALUE in it
// are two separate things. A declaration creates a name. Assignment puts
// something in it. Those are different events, and they can happen at
// surprisingly different times.
var oldSchool = 1; // var — the name exists from the top of the function, holding undefined
let mutable = 2; // let — the name exists from HERE down
const immutable = 3; // const — same as let, plus you can't repoint the name
// And here's the catch that trips everyone at least once: `const` locks the
// NAME, not the object it points at.
const settings: { theme?: string } = { theme: "dark" };
settings.theme = "light"; // legal, and nothing about `const` prevents it
const changed = settings.theme; // => "light"
// Nothing in the language makes a value immutable by declaration. For that you
// need Object.freeze, in file 03.
// A block is a scope. Anything let/const declared in here is invisible outside.
let fromInsideBlock: string;
{
let mutable = "shadowed"; // SHADOWING — a NEW binding that hides the outer one
fromInsideBlock = mutable;
}
const inner = fromInsideBlock; // => "shadowed"
const outer = mutable; // => 2 — the outer binding was never touched
// TEMPORAL DEAD ZONE (TDZ). A `let` name exists from the moment the block
// starts, but reading it before its line runs is an error rather than
// `undefined`. That gap has a name because it is a deliberate design: `var`
// handed you undefined and let you carry on, and `let` stops you.
function hoisting() {
// @ts-expect-error TS2454: TS blocks the early read; JS allows it
const beforeTheDeclaration = typeof varDecl; // => "undefined" — the name is here, just empty
var varDecl = 1;
const afterTheDeclaration = typeof varDecl; // => "number"
return [beforeTheDeclaration, afterTheDeclaration];
}
const hoisted = hoisting(); // => ["undefined", "number"]
// The same read one line above a `let` throws instead:
// ReferenceError: Cannot access 'letDecl' before initialization
// ─── 2. PRIMITIVES AND typeof ────────────────────────────────────────────────
//
// Seven primitive types, plus objects. `typeof` is how you ask at runtime — and
// it gets exactly one answer famously wrong, for backwards-compatibility reasons
// that are now permanent.
const str = "string";
const num = 42;
const bool = true;
const nul = null;
const undef = undefined;
const sym = Symbol("description"); // SYMBOL — every call makes a value nothing else can equal
const big = 9007199254740993n; // BIGINT — the `n` suffix. This number is past what a
// regular `number` can hold exactly, which is the reason bigints exist.
const typeofString = typeof str; // => "string"
const typeofNumber = typeof num; // => "number"
const typeofBoolean = typeof bool; // => "boolean"
const typeofUndefined = typeof undef; // => "undefined"
const typeofSymbol = typeof sym; // => "symbol"
const typeofBigInt = typeof big; // => "bigint"
const typeofNull = typeof nul; // => "object" — the famous wrong one, a 1995 bug that can
// no longer be fixed without breaking the web. `null` is not an object.
const typeofFunction = typeof function () {}; // => "function"
const typeofObject = typeof {}; // => "object"
const typeofArray = typeof []; // => "object" — an array IS an object; Array.isArray is
// the question you actually wanted to ask
const bigIntIsExact = big - 1n; // => 9007199254740992n
const numberIsNot = 9007199254740993 === 9007199254740992; // => true — past 2^53 the
// nearest representable `number` is shared by two integers
// ─── 3. NUMERIC LITERAL FORMS ────────────────────────────────────────────────
//
// Six ways to write a number. They are all the same kind of value once parsed —
// the notation exists purely for you, and leaves no trace in the value.
const dec = 1_000_000; // => 1000000 — NUMERIC SEPARATOR; underscores are ignored
const hex = 0xff; // => 255
const oct = 0o755; // => 493 — modern octal. A bare leading zero (0755) is a legacy form,
// banned in strict mode, which is exactly why the `0o` prefix exists.
const bin = 0b1010; // => 10
const exp = 1.5e3; // => 1500 — exponential
const frac = 0.5; // => 0.5. `.5` parses too, but reads badly next to a dot operator
const sameValue = hex === 255; // => true — the spelling is gone by the time it's a value
// ─── 4. STRING LITERALS AND TEMPLATES ────────────────────────────────────────
const single = 'single';
const double = "double"; // no difference in meaning; pick one and be consistent
const withEscapes = "tab\t quote\" backslash\\"; // ESCAPE SEQUENCES
const escapeLength = withEscapes.length; // => 22 — each escape is ONE character
const emoji = "\u{1F600}"; // UNICODE CODE POINT ESCAPE
const emojiLength = emoji.length; // => 2 — `.length` counts UTF-16 units, not characters
const emojiPoints = [...emoji].length; // => 1 — iteration walks code points instead
const multi = `template
spanning lines`; // only backticks can hold a real newline
const multiLength = multi.split("\n").length; // => 2
const interpolated = `1 + 1 = ${1 + 1}`; // => "1 + 1 = 2" — ${} takes any expression
const nested = `outer ${`inner ${1}`}`; // => "outer inner 1" — and templates nest
// A TAGGED TEMPLATE is a function call in disguise. The tag receives the literal
// text chunks separately from the interpolated values, which is exactly what you
// need in order to escape or sanitise the values without touching the text.
function tag(strings: TemplateStringsArray, ...values: unknown[]) {
return { strings: [...strings], raw: [...strings.raw], values };
}
const tagged = tag`a${1}b${2}c`;
// => { strings: ["a", "b", "c"], raw: ["a", "b", "c"], values: [1, 2] }
// Count them: there is always one more text chunk than value, even when a chunk
// is empty.
const rawText = String.raw`no \n escape processing`; // => "no \\n escape processing"
// `.raw` is the text before escape processing — two characters, backslash and n.
// ─── 5. OPERATORS ────────────────────────────────────────────────────────────
//
// Two separate questions for every expression: how does it GROUP (precedence),
// and what actually RUNS (some operators skip their right side entirely).
const sum = 5 + 2; // => 7
const difference = 5 - 2; // => 3
const product = 5 * 2; // => 10
const quotient = 5 / 2; // => 2.5 — one number type, so no integer division
const remainder = 5 % 2; // => 1
const power = 5 ** 2; // => 25
const negativeRemainder = -5 % 3; // => -2 — `%` is remainder, not modulo: the sign
// follows the LEFT operand. A true modulo would give 1.
const rightAssociative = 2 ** 3 ** 2; // => 512 — `**` groups right-to-left, so this is
// 2 ** (3 ** 2). Every other binary operator groups left.
// `-2 ** 2` doesn't parse at all: the language refuses to guess which you meant,
// so you must write (-2) ** 2 or -(2 ** 2).
// Relational operators on strings compare UTF-16 units one at a time, not meaning.
const alphabetical = "a" < "b"; // => true
const numbersAsText = "10" < "9"; // => true — "1" comes before "9"; no number is involved
const caseMatters = "Z" < "a"; // => true — every capital sorts before every lowercase
const accented = "é" < "z"; // => false — é is U+00E9, past every ASCII letter
// INCREMENT / DECREMENT — the difference is what the EXPRESSION evaluates to,
// not what the variable ends up as. Both leave `i` in the same place.
let i = 0;
const postfix = i++; // => 0 — the value BEFORE the increment. `i` is now 1.
const between = i; // => 1
const prefix = ++i; // => 2 — the value AFTER the increment
// @ts-expect-error TS2367: TS rejects cross-type comparison; JS coerces happily
const looseEquals = 1 == "1"; // => true — `==` converts before comparing
const strictEquals = 1 === ("1" as unknown); // => false — `===` compares type first
const notEqual = 1 !== ("1" as unknown); // => true
// LOGICAL OPERATORS return an OPERAND, not a boolean. That is why they work as
// value-pickers and not merely as tests.
const andResult = true && "returns this"; // => "returns this"
const orResult = false || "returns this"; // => "returns this"
const andKeepsFalsy = 0 && "never reached"; // => 0 — the value that decided it, not `false`
const notResult = !0; // => true — `!` is the one that really does return a boolean
// @ts-expect-error TS2871: TS knows the left side is always nullish
const nullish = null ?? "nullish coalescing"; // => "nullish coalescing"
// @ts-expect-error TS2869: TS knows 0 is never nullish, so the right side is dead
const zeroWithNullish = 0 ?? "not reached"; // => 0 — the whole reason `??` was added
const zeroWithOr = 0 || "falsy triggers ||"; // => "falsy triggers ||"
// LOGICAL ASSIGNMENT. These skip the assignment entirely when they
// short-circuit, which matters enormously if the target has a setter (see 25.2).
let la: string | null = null;
la ??= "assigned, because it was nullish"; // => "assigned, because it was nullish"
let lb = 1;
lb ||= 99; // => 1 — unchanged: `||=` only assigns when the current value is FALSY
let lc = 1;
lc &&= 99; // => 99 — assigned: `&&=` only assigns when the current value is TRUTHY
// BITWISE OPERATORS convert to 32-bit signed integers first, which is where the
// surprises above 2^31 and below zero come from.
const bitAnd = 5 & 3; // => 1
const bitOr = 5 | 3; // => 7
const bitXor = 5 ^ 3; // => 6
const bitNot = ~5; // => -6 — ~n is always -(n + 1)
const shiftLeft = 1 << 3; // => 8
const shiftRight = -16 >> 2; // => -4 — sign-propagating: the sign bit is copied in
const shiftRightUnsigned = -16 >>> 2; // => 1073741820 — zero-filling, so a negative
// number comes back enormous and positive
const ternary = num > 10 ? "big" : "small"; // => "big" — the only three-operand operator
// @ts-expect-error TS2695: TS flags the discarded operands as pointless
const comma = (1, 2, 3); // => 3 — the COMMA OPERATOR runs everything, keeps the last
const voided = void 0; // => undefined — `void x` evaluates x and discards it
// `in`, `instanceof`, `delete`
const point: { x?: number; y: number } = { x: 1, y: 2 }; // `x` optional so `delete` is legal
const hasKey = "x" in point; // => true — and `in` searches the prototype chain too
const isArray = [] instanceof Array; // => true
const deleted = delete point.x; // => true — `delete` reports whether the key is now gone
const afterDelete = "x" in point; // => false
// OPTIONAL CHAINING. The key thing: `?.` doesn't guard one step, it guards the
// WHOLE rest of the chain. If `maybe` is nullish, nothing to its right runs.
const maybe: { deep?: { fn?: () => string } } = {};
const chained = maybe?.deep?.fn?.(); // => undefined — never `null`, whatever the left was
// SPREAD and REST are the same three dots pointing opposite ways: spread lays a
// value out into a list, rest gathers a list into one name.
const pair = [1, 2];
const spreadOut = [0, ...pair, 3]; // => [0, 1, 2, 3]
const [firstItem, ...restItems] = spreadOut; // gathering
const headValue = firstItem; // => 0
const tailValues = restItems; // => [1, 2, 3]
// ─── 6. CONTROL FLOW ─────────────────────────────────────────────────────────
//
// For each of these, the question to keep asking is: which exact statement does
// this keyword attach to? `else`, `break`, and `continue` all bind to something
// specific, and indentation has no say in it.
const score: number = 42; // annotated, or its type would be the literal 42 and every
// `case` below that isn't 42 would be rejected as unreachable
let grade: string;
if (score > 40) grade = "if";
else if (score > 20) grade = "else if"; // there is no `elseif` keyword — this is just an
else grade = "else"; // `if` statement sitting inside the previous `else`
const branchTaken = grade; // => "if"
const switchLog: string[] = [];
switch (score) {
case 41: // a case with no body FALLS THROUGH to the next one
case 42:
switchLog.push("41 or 42");
break; // without this, execution would continue into `default`
default:
switchLog.push("default");
}
const switchResult = switchLog; // => ["41 or 42"]
// `switch` compares with `===`, so it never converts: `switch ("1") { case 1: }`
// never matches, and that is the most common switch bug there is.
const noMatch: string[] = [];
switch ("1" as unknown) {
case 1:
noMatch.push("matched");
break;
default:
noMatch.push("no match");
}
const strictComparison = noMatch; // => ["no match"]
// Since the cases are just expressions, `switch (true)` turns the whole thing
// into an if-else chain — occasionally the clearest way to write one.
let sizeLabel = "";
switch (true) {
case score > 100:
sizeLabel = "huge";
break;
case score > 10:
sizeLabel = "an if-else chain in disguise";
break;
}
const chosenLabel = sizeLabel; // => "an if-else chain in disguise"
// All the cases share ONE scope, so a `const` in one case collides with the
// next. Braces around a case body give it a scope of its own.
let scopedResult = "";
switch (score) {
case 42: {
const scoped = "needs its own block, or it collides with the other cases";
scopedResult = scoped;
break;
}
}
const fromScopedCase = scopedResult; // => "needs its own block, or it collides with the other cases"
const counted: number[] = [];
for (let n = 0; n < 3; n++) counted.push(n);
const forResults = counted; // => [0, 1, 2]
const keys: string[] = [];
for (const key in point) keys.push(key); // FOR-IN yields KEYS, as strings, and walks the
const forInResults = keys; // => ["y"] — `x` was deleted back in section 5 // prototype chain
const values: number[] = [];
for (const value of spreadOut) values.push(value); // FOR-OF yields VALUES, and does not
const forOfResults = values; // => [0, 1, 2, 3] // walk the prototype chain
// Reach for for-of by default. for-in is for when you genuinely want inherited keys.
let w = 0;
while (w < 2) w++; // WHILE — tests before each pass
const afterWhile = w; // => 2
do w++; // DO-WHILE — always runs its body at least once, THEN tests
while (w < 4);
const afterDoWhile = w; // => 4
// All three clauses of a `for` header are optional, including all of them.
let infinite = 0;
for (;;) {
if (++infinite > 2) break; // the only way out
}
const afterInfinite = infinite; // => 3
const converging: string[] = [];
for (let a = 0, b = 10; a < b; a++, b--) { // two names, and a comma operator in the update
converging.push(`${a}:${b}`);
if (a > 1) break;
}
const convergingResults = converging; // => ["0:10", "1:9", "2:8"]
// LABELS let break and continue target an OUTER loop instead of the nearest one.
// Without them you would need a flag variable and an extra check every iteration.
const labelled: string[] = [];
outer: for (let a = 0; a < 3; a++) {
for (let b = 0; b < 3; b++) {
if (b === 1) continue outer; // next iteration of the OUTER loop
if (a === 2) break outer; // leave both loops at once
labelled.push(`${a}${b}`);
}
}
const labelledResults = labelled; // => ["00", "10"]
// A label works on any block, not just loops. `break` then means "skip the rest
// of this block". `continue` does not, because there is nothing to continue.
const blockLog: string[] = [];
labelledBlock: {
blockLog.push("entered");
if (score === 42) break labelledBlock;
blockLog.push("skipped");
}
const blockResults = blockLog; // => ["entered"]
// The single most important loop behaviour to internalise. A `let` in the loop
// HEADER gives each iteration its own binding; `var` gives every iteration one
// shared binding. Closures capture the binding, not the value in it.
const letClosures: (() => number)[] = [];
for (let n = 0; n < 3; n++) letClosures.push(() => n);
const perIteration = letClosures.map((f) => f()); // => [0, 1, 2]
const varClosures: (() => number)[] = [];
for (var v = 0; v < 3; v++) varClosures.push(() => v);
const shared = varClosures.map((f) => f()); // => [3, 3, 3] — all three read the same
// binding, and by the time they run, the loop has left it at the value that failed
// the test
// ─── 7. TRUTHINESS ───────────────────────────────────────────────────────────
//
// Memorise the falsy list rather than reasoning about it — it is short,
// arbitrary, and everything not on it is truthy.
const falsy = [false, 0, -0, 0n, "", null, undefined, NaN]; // the complete list, all eight
const allFalse = falsy.map(Boolean); // => [false, false, false, false, false, false, false, false]
const emptyArray = Boolean([]); // => true — this is the one that catches people
const emptyObject = Boolean({}); // => true
const zeroAsText = Boolean("0"); // => true — a non-empty string, and that is the only test
const singleSpace = Boolean(" "); // => true
// ─── 8. AUTOMATIC SEMICOLON INSERTION ────────────────────────────────────────
//
// The rule is one sentence: a line break ends a statement only when the
// statement CAN'T continue. So the danger is not the semicolons you forgot — it
// is the lines that happily join up when you wanted two statements.
//
// 1. return/throw/break/continue/yield refuse to have a newline before their
// operand. So this returns undefined and leaves an orphaned object:
// return
// { ok: true }
//
// 2. A line starting with ( [ ` + - / continues the PREVIOUS line:
// const a = b
// (c) // reads as b(c)
// [1].forEach // reads as indexing the line above
// This is why defensive style starts such lines with a semicolon.
//
// 3. Postfix ++ and -- never carry over a newline either.
function asiTrap() {
return; // the newline below would have inserted this semicolon for you
// eslint-disable-next-line no-unreachable
({ unreachable: true });
}
const trapped = asiTrap(); // => undefined — the object literal is unreachable code
// ─── 9. STRICT MODE ──────────────────────────────────────────────────────────
//
// You are almost certainly already in strict mode: every module and every class
// body is strict whether you ask or not. It is worth knowing what it changed,
// because the old behaviour still shows up in .cjs files and <script> tags.
//
// - assigning to an undeclared name is a ReferenceError, not a new global
// - `this` in a plain call is undefined, not the global object
// - duplicate parameter names and legacy octal literals are SyntaxErrors
// - writing to a read-only property throws instead of failing silently
"use strict"; // and here it does nothing at all: a DIRECTIVE PROLOGUE only counts as
// the first statement of a file or a function body. This file was already strict
// because it is a module.
const globalKind = typeof globalThis; // => "object" — the portable way to reach the
// global object, whichever host you are on