Operators and Expressions
Why Operators Matter More Than You Think
Most people skim over operators like they're just basic math symbols. Big mistake. Operators are the verbs of JavaScript — they make things happen. And some of them have surprising behaviors that trip up even experienced developers.
You probably know + adds numbers. But did you know + also concatenates strings, converts values to numbers, and its behavior changes based on what types it encounters? That's just one operator. We've got a whole toolbox to unpack.
Think of operators as tiny functions with special syntax. a + b is really like calling add(a, b) — it takes inputs, applies rules, and returns a result. The key insight: operators don't just compute values, they can coerce types, short-circuit evaluation, and control program flow. Understanding what each operator actually does under the hood turns you from someone who writes code that happens to work into someone who knows why it works.
Arithmetic Operators
The basics, with a few twists you might not expect.
10 + 3 // 13
10 - 3 // 7
10 * 3 // 30
10 / 3 // 3.3333...
10 % 3 // 1 (remainder)
10 ** 3 // 1000 (exponentiation)
The + Operator's Double Life
The + operator is overloaded — it does addition with numbers and concatenation with strings. When one operand is a string, the other gets coerced to a string too.
5 + 3 // 8 — both numbers, addition
"5" + 3 // "53" — string wins, concatenation
5 + "3" // "53" — string wins again
true + 1 // 2 — true coerces to 1
null + 5 // 5 — null coerces to 0
"" + 42 // "42" — sneaky way to convert to string
The + operator checks if either operand is a string (after calling ToPrimitive). If so, it concatenates. Otherwise, it adds. This is why [] + [] is "" — both arrays become empty strings via toString(), then get concatenated.
Unary Plus and Minus
The unary + converts its operand to a number. It's the shortest way to do an explicit type conversion.
+"42" // 42
+true // 1
+false // 0
+null // 0
+undefined // NaN
+"hello" // NaN
+"" // 0
The unary - does the same conversion and then negates.
Remainder vs Modulo
JavaScript's % is the remainder operator, not modulo. The difference matters with negative numbers.
7 % 3 // 1
-7 % 3 // -1 (remainder keeps the sign of the dividend)
// True modulo would give 2 for -7 % 3
// If you need modulo: ((n % m) + m) % m
Exponentiation
The ** operator was added in ES2016. It's right-associative, which surprises people.
2 ** 3 // 8
2 ** 3 ** 2 // 512 — evaluated as 2 ** (3 ** 2), not (2 ** 3) ** 2
Assignment Operators
Assignment returns the assigned value, which is why chaining works (though you should avoid it for readability).
let x = 10;
x += 5; // x = x + 5 → 15
x -= 3; // x = x - 3 → 12
x *= 2; // x = x * 2 → 24
x /= 4; // x = x / 4 → 6
x %= 4; // x = x % 4 → 2
x **= 3; // x = x ** 3 → 8
Logical Assignment (ES2021)
These are newer and incredibly useful. They combine logical operators with assignment.
// OR assignment — assign if current value is falsy
let name = "";
name ||= "Anonymous"; // "Anonymous"
// Nullish assignment — assign only if null/undefined
let config = null;
config ??= { theme: "dark" }; // { theme: "dark" }
// AND assignment — assign only if current value is truthy
let data = { count: 5 };
data &&= { ...data, updated: true }; // { count: 5, updated: true }
These operators short-circuit. x ||= y does NOT always evaluate y. If x is already truthy, y is never touched. This matters when y has side effects like function calls.
Comparison Operators
Loose Equality (==) vs Strict Equality (===)
This is one of the most important distinctions in JavaScript. The == operator performs type coercion before comparing. The === operator compares without any conversion.
5 == "5" // true — string "5" is coerced to number 5
5 === "5" // false — different types, no conversion
null == undefined // true — special case in the spec
null === undefined // false — different types
0 == false // true — false becomes 0
0 === false // false — number vs boolean
The full algorithm for == is defined in the ECMAScript specification. The short version: it tries to convert both sides to the same type, with a preference for numbers. Booleans become numbers first, objects get ToPrimitive called on them, and null only equals undefined.
"" == false // true — both become 0
"0" == false // true — false→0, "0"→0
" " == false // true — false→0, " "→0 (whitespace string is 0)
[] == false // true — false→0, []→""→0
[1] == true // true — true→1, [1]→"1"→1
Use === everywhere. The only reasonable exception is x == null, which catches both null and undefined in one check. Every other use of == is asking for trouble.
Relational Comparisons
The <, >, <=, and >= operators also coerce types.
"10" > "9" // false! — string comparison is lexicographic, "1" < "9"
"10" > 9 // true — one is a number, so "10" becomes 10
null > 0 // false
null < 0 // false
null == 0 // false — null only equals undefined
null >= 0 // true — coerces null to 0 for relational comparison
Logical Operators and Short-Circuit Evaluation
Here's where things get interesting. JavaScript's logical operators don't return true or false — they return one of their operands.
The OR Operator (||)
The || operator returns the first truthy value, or the last value if all are falsy.
"hello" || "world" // "hello" — first truthy
"" || "fallback" // "fallback" — "" is falsy, move to next
0 || null || "found" // "found" — 0 and null are falsy
0 || "" || null // null — all falsy, returns the last one
The AND Operator (&&)
The && operator returns the first falsy value, or the last value if all are truthy.
"hello" && "world" // "world" — both truthy, returns last
"" && "world" // "" — first falsy, short-circuits
1 && 2 && 3 // 3 — all truthy, returns last
1 && 0 && 3 // 0 — first falsy value
Short-Circuit Evaluation
This isn't just trivia — short-circuiting is a pattern you'll use daily.
// Conditional rendering (React pattern)
isLoggedIn && <Dashboard />
// Safe property access (before optional chaining existed)
user && user.profile && user.profile.name
// Default values (before nullish coalescing)
const port = process.env.PORT || 3000;
// Guard clause
data && processData(data);
The NOT Operator (!)
The ! operator converts to boolean and negates. Double !! is a common trick to convert any value to its boolean equivalent.
!"" // true
!0 // true
!null // true
!"hello" // false
![] // false — arrays are truthy (even empty ones!)
!!"" // false — double negation = ToBoolean
!!"hello" // true
!!0 // false
!!42 // true
Nullish Coalescing (??) vs OR (||)
This is one of the most important distinctions in modern JavaScript, and getting it wrong causes real bugs.
The || operator treats all falsy values as "missing" — that includes 0, "", false, and NaN.
The ?? operator only treats null and undefined as "missing". Everything else passes through.
// The problem with ||
0 || 10 // 10 — but 0 was a valid value!
"" || "N/A" // "N/A" — but "" was intentional!
false || true // true — but false was the correct setting!
// ?? respects falsy values
0 ?? 10 // 0 — 0 is not null/undefined, so it passes
"" ?? "N/A" // "" — empty string passes through
false ?? true // false — false passes through
// ?? only triggers on null/undefined
null ?? "default" // "default"
undefined ?? "default" // "default"
When to use ?? vs ||
Use ?? when 0, "", or false are valid values in your domain. Use || when you truly want to catch all falsy values. In practice, ?? is almost always what you want for default values.
// User settings — 0 volume is valid, "" username is not
const volume = userSettings.volume ?? 50; // use ??
const username = userInput.name || "Guest"; // use || (empty string = no input)
// API responses — 0 count is valid data
const count = response.count ?? 0; // use ??
// Feature flags — false is a valid setting
const enabled = config.featureFlag ?? true; // use ??JavaScript throws a SyntaxError if you mix ?? with || or && directly. You must use parentheses to clarify precedence.
// SyntaxError:
// a || b ?? c
// Valid:
(a || b) ?? c
a || (b ?? c)This was a deliberate design decision to prevent ambiguous expressions.
Optional Chaining (?.)
Before optional chaining, accessing deeply nested properties was painful.
// The old way — verbose and fragile
const street = user && user.address && user.address.street;
// With optional chaining
const street = user?.address?.street;
The ?. operator short-circuits to undefined if the left side is null or undefined. It works with property access, method calls, and bracket notation.
const user = null;
user?.name // undefined (not TypeError)
user?.getName() // undefined (method call)
user?.["first-name"] // undefined (bracket notation)
user?.friends[0] // undefined (short-circuits before [0])
Optional Chaining with Methods
const api = {
getData: () => [1, 2, 3]
};
api.getData?.() // [1, 2, 3] — method exists, call it
api.deleteData?.() // undefined — method doesn't exist, skip it
Optional chaining does NOT protect the right side of the chain from errors if the left side is valid. user?.friends[0] will throw if user.friends is undefined — it only checked user, not user.friends. You'd need user?.friends?.[0].
Combining ?. with ??
These two operators pair perfectly together.
const displayName = user?.profile?.name ?? "Anonymous";
const itemCount = cart?.items?.length ?? 0;
const theme = settings?.appearance?.theme ?? "system";
The Ternary Operator
The only operator that takes three operands. It's an expression (returns a value), not a statement.
const status = score >= 90 ? "excellent" : "keep going";
const label = count === 1 ? "item" : "items";
Nested Ternaries
You can nest them, but please format them properly or don't nest them at all.
// Readable nested ternary (align the ? and :)
const grade =
score >= 90 ? "A" :
score >= 80 ? "B" :
score >= 70 ? "C" :
score >= 60 ? "D" : "F";
// If it's more complex than this, use if/else or a lookup object
const gradeMap = { 90: "A", 80: "B", 70: "C", 60: "D" };
Unlike if/else, the ternary operator produces a value. That's why you can use it in JSX, template literals, variable declarations, and function arguments — anywhere an expression is valid.
The Comma Operator
The comma operator evaluates all of its operands from left to right and returns the value of the last one. Almost nobody uses it, and that's probably fine — but you should recognize it when you see it.
const result = (1, 2, 3); // 3 — evaluates all, returns last
let x = 0;
const y = (x++, x++, x); // 2 — x increments twice, returns final x
Where You Actually See It
The most common place is for loops with multiple counters.
for (let i = 0, j = 10; i < j; i++, j--) {
console.log(i, j);
}
// 0 10
// 1 9
// 2 8
// 3 7
// 4 6
Comma operator vs comma separator
Don't confuse the comma operator with commas used as separators. When you write [1, 2, 3] or foo(a, b), those commas are separators — part of the array/function syntax. The comma operator only applies in expression contexts where commas aren't already serving another purpose.
// Comma separators — NOT the comma operator
const arr = [1, 2, 3]; // array elements
function f(a, b, c) {} // parameters
const { x, y } = point; // destructuring
// Comma operator — evaluates both, returns last
const val = (doSomething(), getResult());Operator Precedence Gotchas
Operator precedence determines which parts of an expression are evaluated first. Most of it is intuitive (multiplication before addition), but some cases are genuinely surprising.
The Classic Gotchas
// typeof and unary operators bind tighter than you think
typeof 1 + 2 // "number2" — (typeof 1) + 2 → "number" + 2
typeof (1 + 2) // "number" — typeof 3
// Assignment has very low precedence
let x;
x = 1 + 2 * 3; // 7 — multiplication first, then addition, then assignment
// Logical OR vs AND — AND binds tighter
true || false && false // true — evaluated as true || (false && false)
// Nullish coalescing has low precedence
a ?? b || c // SyntaxError! Must use parens
When in Doubt, Use Parentheses
The golden rule: if you have to think about precedence, add parentheses. Your future self (and your teammates) will thank you.
// Don't rely on precedence for complex expressions
const result = a && b || c && d; // works, but hard to read
// Be explicit
const result = (a && b) || (c && d); // same thing, instantly clear
- 1Use === everywhere, except x == null to catch both null and undefined
- 2Use ?? for defaults when 0, '', or false are valid values. Use || only when all falsy values should trigger the fallback
- 3Never mix ?? with || or && without parentheses — it is a SyntaxError by design
- 4Optional chaining (?.) only guards the left side — user?.friends[0] does NOT protect against friends being undefined
- 5The + operator concatenates if either side is a string. The - operator always does math
- 6Logical operators return operand values, not booleans — 'hello' && 'world' returns 'world', not true
- 7When precedence is not obvious, add parentheses. Clever code is bad code
| What developers do | What they should do |
|---|---|
| Using || for defaults when 0 or empty string are valid: count || 10 || treats 0, '', false, and NaN as 'missing'. If count is legitimately 0, || replaces it with 10. Use ?? which only triggers on null/undefined. | Using ?? for defaults: count ?? 10 |
| Chaining optional access without covering each level: user?.address.street If user exists but user.address is undefined, accessing .street on undefined still throws a TypeError. You need ?. at every uncertain level. | Guarding each level: user?.address?.street |
| Relying on == for comparisons: if (x == 0) With ==, values like '', false, null, and [] all equal 0 due to type coercion. Strict equality removes the guessing game. | Using strict equality: if (x === 0) |
| Assuming logical operators return booleans: const flag = 'a' && 'b' 'a' && 'b' returns 'b', not true. If you need an actual boolean (for APIs, serialization, etc.), wrap with !! or use Boolean(). | Using !! if you need a boolean: const flag = !!('a' && 'b') |