Operators & flow
Every operator here has an interview question hiding behind it.
Arithmetic — and the one that lies
Six operators: + - * / % **. Five of them only ever do
math. + is the odd one out — if either side is a
string, it stops adding and starts concatenating.
| Expression | Result | Why |
|---|---|---|
5 - "2" |
3 |
- only means subtract — the string is coerced to a number |
5 + "2" |
"52" |
+ sees a string and switches to concatenation |
"5" + 2 + 1 |
"521" |
left-to-right — once it's a string, it stays a string |
5 + 2 + "1" |
"71" |
5 + 2 runs first (both numbers), then it meets the string |
10 % 3 |
1 |
remainder, not "percent" — the sign follows the left operand |
2 ** 3 ** 2 |
512 |
** is right-associative: 2 ** (3 ** 2), not (2 ** 3) ** 2 |
console.log([] + []); // what happens?
console.log([] + {}); // what happens?
Every array/object first converts to a primitive before +
ever runs. [].toString() is "", so
[] + [] is "" + "" → "".
{}.toString() is "[object Object]", so
[] + {} → "[object Object]".
Swap the order — {} + [] — and if that {}
is the very first token of a statement rather than sitting
inside an expression, the parser reads it as an empty
block, not an object literal. What's left is a new statement,
+[] — unary plus on an empty array — which is
0. Wrap it in anything (console.log(...),
an assignment, parens) and {} is back in expression
position, parsed as an object literal again, giving the same
"[object Object]" as before:
// {} is the first token of a statement here — parsed as a block
{} + []; // two statements: an empty block, then +[] (discarded)
// {} is inside an expression here — parsed as an object literal
console.log({} + []); // "[object Object]"
This is exactly why node -p "{} + []" prints 0
— the REPL evaluates that line as a standalone statement, so
{} lands in statement position. The moment it's an
argument to something else, it can't be a statement anymore.
+ triggers
ToPrimitive on both sides before it decides whether it's
adding or concatenating. Every other arithmetic operator forces
ToNumber and never looks back."
Unary, increment, typeof
-x and +x coerce to a number without any other
math — +x is a fast, common way to turn a string into a
number. ++ and -- exist in two flavors that
differ only in when they hand back a value.
let x = 5;
console.log(x++); // 5 — returns the OLD value, then increments
console.log(x); // 6
console.log(++x); // 7 — increments FIRST, then returns
let y = x++ + ++x; works, but nobody can read it at a
glance — including you, in six months. Put the increment on its own
line.
Assignment — plain and compound
| Operator | Means |
|---|---|
x += y | x = x + y |
x -= y, *=, /=, %=, **= | same pattern for each |
x &&= y | if (x) x = y — assign only when x is truthy |
x ||= y | if (!x) x = y — assign only when x is falsy |
x ??= y | if (x == null) x = y — assign only when x is null/undefined |
The logical-assignment trio (ES2021) are the standard way to fill in a
missing config value without an if block:
const config = {};
config.retries ??= 3; // only sets it if it's null/undefined
console.log(config.retries); // 3
Comparison — the interview's favorite trap
== compares after converting both sides to a common type.
=== refuses to convert anything — different types is an
automatic false. That's the whole rule; the coercion table
is just that rule played out across every type pair.
All true under == — every one of them false under ===.
All false — even the last one, which looks like it should
chain. "" == 0 is true, and 0 == "0"
is true, but == isn't transitive, so
"" == "0" is false on its own. That non-transitivity
is the strongest argument against == that exists.
=== and !==
by default. The one accepted exception is x == null, which
deliberately catches both null and undefined
in one check.
Logical operators — short-circuit, not just booleans
&& and || don't return
true/false — they return
one of their actual operands. && returns the
first falsy value it finds, or the last value if none are falsy.
|| returns the first truthy value, or the last value if
none are truthy. Once the answer is decided, the other side is never
even evaluated.
function log(x) { console.log("checked", x); return x; }
log(false) && log("never runs"); // stops at the first falsy value
log(true) || log("never runs"); // stops at the first truthy value
That short-circuit is what makes the classic default-value idiom work — and also what makes it dangerous:
function greet(name) {
const who = name || "friend"; // falls back on ANY falsy name
return "Hi " + who;
}
greet(""); // "Hi friend" — is that what you wanted?
?? — the fix for || 's blind spot
?? only falls back on null or
undefined — 0, "", and
false all survive it untouched. That's the entire reason
it exists.
| Value on the left | value || "fallback" |
value ?? "fallback" |
|---|---|---|
0 | "fallback" | 0 |
"" | "fallback" | "" |
false | "fallback" | false |
null | "fallback" | "fallback" |
undefined | "fallback" | "fallback" |
a || b ?? c is a SyntaxError — JS refuses to
guess which one you meant to run first. Parenthesize:
(a || b) ?? c.
?. — optional chaining
Before ?., reaching into a maybe-missing property meant a
wall of && guards. Now the chain just stops — and
returns undefined — the moment it hits
null/undefined.
const user = { profile: null };
user.profile.bio; // 💥 throws — profile is null
user.profile?.bio; // undefined — stops safely
user.greet?.(); // calls greet() only if it exists
user.tags?.[0]; // safe computed access too
It short-circuits the whole rest of the chain, not just the
next step: a?.b.c.d — if a is
null, the entire expression is undefined;
.c.d never even gets attempted.
user.profile?.bio = "hi" is a SyntaxError —
?. can only be used to read, never as the target
of an assignment.
The ternary
const label = age >= 18 ? "adult" : "minor";
A full if/else squeezed into one expression — which is
exactly its advantage: it produces a value, so it can sit
inside a return, a template, or a prop. Nesting one is
fine; nesting two gets unreadable fast — reach for
if/else once you're past one level.
if / else / switch
if only cares about truthy vs falsy — the same rules from
the last chapter apply here with no exceptions. switch
compares with === (no coercion) and, unlike
if/else, falls through to the next case unless you
break.
function trafficAction(color) {
switch (color) {
case "red":
return "stop";
case "yellow":
case "amber": // two labels, one body — the fall-through you actually want
return "slow down";
case "green":
return "go";
default:
return "unknown";
}
}
break anywhere else and it silently keeps
running into the next case's code — no error, just wrong behavior. If
a case doesn't return, it needs an explicit
break.
Loops
| Loop | When to use it |
|---|---|
for (let i = 0; i < n; i++) | you need the index, or a custom step |
while (cond) | you don't know the count in advance |
do { } while (cond) | the body must run at least once, condition checked after |
for (const x of iterable) | you just want the values — arrays, strings, Maps, Sets |
for (const k in obj) | you want an object's enumerable keys — plain objects only |
for...of vs for...in — don't mix these up
This pair gets confused constantly, and the confusion has a real cost:
for...in on an array walks its keys as strings,
includes any inherited enumerable properties, and makes no promise
about numeric ordering. for...of walks
values, in order, and works on anything iterable.
const arr = ["a", "b", "c"];
for (const v of arr) console.log(v); // "a" "b" "c" — values, in order
for (const k in arr) console.log(k); // "0" "1" "2" — STRING indices
for...of for arrays and
anything iterable. for...in only for plain objects — and
even there, Object.keys/values/entries is usually clearer.
break, continue, and labels
outer: for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (j === 1) continue outer; // skip to the NEXT i, not just this j
if (i === 2) break outer; // exit BOTH loops
console.log(i, j);
}
}
Labels are rare in real code — one un-labeled break or
continue only ever touches its nearest enclosing loop. But
recognize the syntax; it shows up in coding-round trick questions more
than it does in production code.
Precedence & associativity, compressed
| Highest → lowest | Associativity |
|---|---|
() grouping, . ?. [] member access | left → right |
unary: ! ~ + - ++ -- typeof | right → left |
** | right → left |
* / % | left → right |
+ - | left → right |
< <= > >= | left → right |
== != === !== | left → right |
&& | left → right |
||, ?? | left → right |
?: ternary | right → left |
= += -= &&= ||= ??= … | right → left |
console.log(1 < 2 < 3); // what happens?
console.log(3 > 2 > 1); // what happens?
Both read left to right because < and >
have no special chaining rule in JS — it's two separate comparisons.
1 < 2 < 3 is (1 < 2) < 3 →
true < 3 → 1 < 3 → true.
3 > 2 > 1 is (3 > 2) > 1 →
true > 1 → 1 > 1 →
false. Same shape, opposite answer — that's the trap.
Opens in the editor — write it, run it, and check it against real tests.