Skip to main content
JavaScript · lesson 5 of 24
JavaScript beginner Lesson 5 of 24

JavaScript Operators

Master JavaScript operators — arithmetic, comparison, logical, nullish coalescing, optional chaining, spread, and ternary — with real-world examples.

By SkillByExample · Updated

Operators combine values into new ones. Most behave as they do in any language; the interesting parts of JavaScript’s are the ones that convert types on the way — +, == — and the ones that return values rather than plain booleans — &&, ||, ??. Every example is a complete file run with node.

Arithmetic

The usual operators, plus ** for powers and % for the remainder.

console.log(10 + 3, 10 - 3, 10 * 3, 10 / 3);
console.log(10 % 3, -10 % 3, 10 ** 3, 2 ** 0.5);
console.log(7 / 0, -7 / 0, 0 / 0);
console.log(0.1 + 0.2, 0.1 + 0.2 === 0.3);

let x = 5;
console.log(x++, x);
console.log(++x, x);
$ node arith.js
13 7 30 3.3333333333333335
1 -1 1000 1.4142135623730951
Infinity -Infinity NaN
0.30000000000000004 false
5 6
7 7

Division always produces a floating-point number — there is no integer division (use Math.trunc(10 / 3)). % takes the sign of the left operand, so -10 % 3 is -1, not 2. Dividing by zero gives Infinity, and 0 / 0 gives NaN — no exceptions. 0.1 + 0.2 shows binary floating point: compare decimals with a tolerance, or work in integers. x++ returns the old value, then increments; ++x increments, then returns.

+ Also Concatenates

If either operand of + is a string, the other is converted and the result is a string. It’s evaluated left to right.

console.log("5" + 3, typeof ("5" + 3));
console.log(5 + 3 + "px");
console.log("px" + 5 + 3);
console.log("5" - 3, "5" * "2", "abc" * 2);
console.log(+"42", +"", +"4 2", Number("42px"), parseInt("42px"));
$ node plus.js
53 string
8px
px53
2 10 NaN
42 0 NaN NaN 42

5 + 3 + "px" adds first (8) and then concatenates; "px" + 5 + 3 becomes a string at the first step, so the 3 is glued on too. Only + concatenates — -, * and / always convert to numbers, which is why "5" - 3 is 2. Unary + is a compact Number(); note that +"" is 0, while parseInt reads leading digits and stops, so "42px" gives 42 where Number gives NaN.

Assignment and Logical Assignment

Compound operators update a variable in place; ??=, ||= and &&= assign only when a condition holds.

let n = 10;
n += 5; n -= 3; n *= 2;
console.log(n);
n /= 4; n **= 2; n %= 10;
console.log(n);

const settings = { retries: 0, theme: "", name: null };
settings.retries ||= 3;
settings.theme ??= "dark";
settings.name ??= "Anonymous";
console.log(settings);

let token = "abc";
token &&= token.toUpperCase();
let missing = null;
missing &&= missing.toUpperCase();
console.log(token, missing);
$ node assign.js
24
6
{ retries: 3, theme: '', name: 'Anonymous' }
ABC null

||= treated the legitimate 0 retries as “missing” and overwrote it with 3 — the same trap as || below. ??= only fills in null/undefined, so the empty-string theme survived and the null name was replaced. &&= is the mirror image: it only assigns when the current value is truthy, which makes it a safe “transform if present”.

=== vs ==

=== compares without converting types. == converts first, following rules few people remember.

const pairs = [
  [1, "1"], [0, false], [0, ""], ["", false], ["0", false],
  [null, undefined], [null, 0], [NaN, NaN], [[1], "1"],
];

for (const [a, b] of pairs) {
  const show = (v) => (typeof v === "string" ? JSON.stringify(v) : Array.isArray(v) ? `[${v}]` : String(v));
  console.log(`${show(a)} vs ${show(b)}`.padEnd(22), "== ", String(a == b).padEnd(5), "=== ", a === b);
}
console.log(Object.is(NaN, NaN), Number.isNaN(NaN));
$ node equality.js
1 vs "1"               ==  true  ===  false
0 vs false             ==  true  ===  false
0 vs ""                ==  true  ===  false
"" vs false            ==  true  ===  false
"0" vs false           ==  true  ===  false
null vs undefined      ==  true  ===  false
null vs 0              ==  false ===  false
NaN vs NaN             ==  false ===  false
[1] vs "1"             ==  true  ===  false
true true

0 == "" and "0" == false are both true, yet "" == "0" would be false — loose equality isn’t even transitive. NaN isn’t equal to anything, itself included, under either operator; test it with Number.isNaN or Object.is. The rule: always use ===. The one widely accepted exception is value == null, which is true for exactly null and undefined — and nothing else, as null == 0 shows.

Relational Operators and Strings

<, >, <=, >= compare numbers numerically — but two strings are compared character by character.

console.log(10 > 9, "10" > "9", "10" > 9);
console.log("banana" > "apple", "Zebra" < "apple");
console.log(["10", "9", "1", "100"].sort());
console.log(["10", "9", "1", "100"].sort((a, b) => a - b));
console.log(["Zebra", "apple", "Äpfel"].sort((a, b) => a.localeCompare(b)));
$ node relational.js
true false true
true true
[ '1', '10', '100', '9' ]
[ '1', '9', '10', '100' ]
[ 'Äpfel', 'apple', 'Zebra' ]

"10" > "9" is false because "1" sorts before "9"; as soon as one side is a number ("10" > 9) both are compared as numbers. Uppercase letters come before all lowercase ones in Unicode, so "Zebra" < "apple". The default sort() compares as strings too — hence ["1", "10", "100", "9"]. Pass a comparator: a - b for numbers, localeCompare for human-sorted text.

&& and || Return Values, Not Booleans

&& returns the first falsy operand (or the last one); || returns the first truthy operand (or the last one). Evaluation stops as soon as the answer is known.

function expensive(label) {
  console.log(`  expensive(${label}) ran`);
  return label;
}

console.log("hello" && 42);
console.log(null && expensive("A"));
console.log(0 || "fallback");
console.log("exists" || expensive("B"));
console.log(0 || "" || null);
console.log(!!"hello", !!0, !![], !!{}, !!"0", !!NaN);
$ node logical.js
42
null
fallback
exists
null
true false true true true false

expensive never ran: null && … is already falsy and "exists" || … already truthy, so the right side was skipped — short-circuiting. That’s what makes user && user.save() a null guard. When nothing is truthy, || returns the last operand (null), not false. !! converts to a real boolean; note that [], {} and "0" are all truthy.

?? vs ||

?? (nullish coalescing) falls back only on null or undefined. || falls back on every falsy value.

const inputs = [0, "", false, null, undefined, "Alice"];
for (const value of inputs) {
  console.log(
    String(JSON.stringify(value)).padEnd(10),
    "|| ->", String(JSON.stringify(value || "default")).padEnd(10),
    "?? ->", JSON.stringify(value ?? "default"),
  );
}

const userScore = 0;
console.log(`Score: ${userScore || "No score"}`);
console.log(`Score: ${userScore ?? "No score"}`);
$ node nullish.js
0          || -> "default"  ?? -> 0
""         || -> "default"  ?? -> ""
false      || -> "default"  ?? -> false
null       || -> "default"  ?? -> "default"
undefined  || -> "default"  ?? -> "default"
"Alice"    || -> "Alice"    ?? -> "Alice"
Score: No score
Score: 0

For 0, "" and false, || discards a real value; ?? keeps it. Use ?? for defaults whenever a falsy value could be valid — a score of 0, an empty name, a disabled flag. (JSON.stringify(undefined) is itself undefined, which is why that row prints without quotes.)

Optional Chaining (?.)

?. stops and returns undefined if the value to its left is null or undefined, instead of throwing.

const user = { name: "Alice", address: { city: "Tokyo" }, greet() { return "hi"; } };
const guest = null;

console.log(user?.address?.city);
console.log(user.address?.zip);
console.log(user.contacts?.[0]?.email);
console.log(user.greet?.(), user.wave?.());
console.log(guest?.name, guest?.address.city);
console.log(user.contacts?.[0]?.email ?? "no email on file");

try { console.log(user.contacts[0].email); }
catch (err) { console.log(err.message); }
$ node optional.js
Tokyo
undefined
undefined
hi undefined
undefined undefined
no email on file
Cannot read properties of undefined (reading '0')

?.[…] and ?.() guard index access and calls. guest?.address.city is also safe: once ?. short-circuits, the entire rest of the chain is skipped. Paired with ??, it reads a deeply nested value with a default in one line. Without it, the first missing level throws — the most common TypeError in JavaScript.

Spread and Rest

... spreads an iterable into places that take several values, and — in a parameter list — collects extra arguments into an array.

const a = [1, 2, 3];
const b = [4, 5, 6];
console.log([...a, ...b], [..."hey"]);

const numbers = [5, 1, 8, 3];
console.log(Math.max(...numbers), Math.max(numbers));

const config = { ...{ theme: "dark", lang: "en" }, ...{ theme: "light" } };
console.log(config);

function sum(first, ...rest) {
  console.log(`first=${first} rest=${JSON.stringify(rest)}`);
  return rest.reduce((acc, n) => acc + n, first);
}
console.log(sum(1, 2, 3, 4));
$ node spread.js
[ 1, 2, 3, 4, 5, 6 ] [ 'h', 'e', 'y' ]
8 NaN
{ theme: 'light', lang: 'en' }
first=1 rest=[2,3,4]
10

A string is iterable, so spreading it gives characters. Math.max(numbers) without spread tries to convert the whole array to one number and gets NaN. In objects, later keys win. The same three dots in a parameter list do the reverse — rest is a real array.

Ternary Operator

condition ? a : b is an expression, so it works where statements can’t — inside template literals, arguments and JSX.

for (const count of [0, 1, 5]) {
  console.log(`You have ${count} ${count === 1 ? "message" : "messages"}`);
}

const grade = (score) => (score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F");
console.log([95, 85, 72, 40].map(grade).join(" "));
$ node ternary.js
You have 0 messages
You have 1 message
You have 5 messages
A B C F

A chain of ternaries works (they associate to the right), and formatted one-per-line it reads like a lookup table. Beyond that, prefer if/else or an object lookup — nested ternaries inside branches are where readability goes.

Precedence

When operators mix, precedence decides grouping. Parentheses make it explicit.

console.log(2 + 3 * 4, (2 + 3) * 4);
console.log(true || false && false, (true || false) && false);
console.log(2 ** 3 ** 2, (2 ** 3) ** 2);

for (const src of ['null ?? "a" || "b"', "-2 ** 2"]) {
  try { new Function(`return ${src}`); }
  catch (err) { console.log(`${src.padEnd(20)} -> ${err.name}: ${err.message}`); }
}
console.log((null ?? "a") || "b", (-2) ** 2);
$ node precedence.js
14 20
true false
512 64
null ?? "a" || "b"   -> SyntaxError: Unexpected token '||'
-2 ** 2              -> SyntaxError: Unary operator used immediately before exponentiation expression. Parenthesis must be used to disambiguate operator precedence
a 4

&& binds tighter than ||, and ** groups right to left (2 ** 9 = 512). Two combinations are deliberately syntax errors because they’re ambiguous: mixing ?? with ||/&& without parentheses, and a unary minus directly before **. The code is compiled with new Function here only so the error can be caught and printed; in a normal file it would stop the script from running at all.

Practice

1. Write isBlank(value) that is true for null, undefined and "" but false for 0 and false.
const isBlank = (value) => value == null || value === "";
for (const v of [null, undefined, "", 0, false, "x"]) {
  console.log(JSON.stringify(v) ?? "undefined", isBlank(v));
}
$ node p1.js
null true
undefined true
"" true
0 false
false false
"x" false

value == null is the one idiomatic use of ==: it covers both null and undefined.

2. Read order.customer.address.postcode from three orders, one with no customer and one with no address, defaulting to "n/a".
const orders = [
  { id: 1, customer: { address: { postcode: "SW1A 1AA" } } },
  { id: 2 },
  { id: 3, customer: {} },
];
for (const order of orders) {
  console.log(order.id, order.customer?.address?.postcode ?? "n/a");
}
$ node p2.js
1 SW1A 1AA
2 n/a
3 n/a
3. Sort the prices ["19.99", "5", "100", "9.5"] from lowest to highest, and show what plain sort() does.
const prices = ["19.99", "5", "100", "9.5"];
console.log([...prices].sort());
console.log([...prices].sort((a, b) => a - b));
console.log([...prices].map(Number).sort((a, b) => a - b));
$ node p3.js
[ '100', '19.99', '5', '9.5' ]
[ '5', '9.5', '19.99', '100' ]
[ 5, 9.5, 19.99, 100 ]

a - b converts the strings to numbers for the comparison only; mapping to Number first gives you numbers in the result as well.

4. Write average(...nums) using rest parameters that returns 0 when called with nothing.
const average = (...nums) => (nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0);
console.log(average(2, 4, 9));
console.log(average());
console.log(average(...[10, 20]));
$ node p4.js
5
0
15

Without the nums.length check, average() would be 0 / 0, which is NaN.

Frequently Asked Questions

When should I use == instead of ===?
Almost never. The only widely accepted use of == is the null check: `value == null` catches both null and undefined in one expression. For everything else, use === to avoid coercion surprises.
What is the difference between ?? and ||?
|| treats any falsy value (0, '', false, NaN) as missing and falls back. ?? only treats null and undefined as missing. Use ?? when 0 or an empty string is a valid value you want to keep.
What does the optional chaining operator ?. do?
It short-circuits and returns undefined if the value to the left is null or undefined, instead of throwing a TypeError. It's essential for safely traversing nested objects where any level may be absent.