JavaScript NotesRohit’s interview study guide
Chapter 01

Language Fundamentals

Types, variables, hoisting, equality and the small operators that trip people up in interviews.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Types and dynamic typing

JavaScript is dynamically typed: a variable is not bound to a type, only its current value is. The type lives on the value, and the engine checks it at the moment an operation runs — not when the code is written. Statically typed languages (Java, Kotlin, TypeScript at compile time) reject a mismatched assignment before the program starts; JavaScript happily runs it and only fails, if at all, when an operation can't be performed on the value it actually finds.

A useful mental model: a variable is a label on a slot, and the slot can hold any kind of value. Asking "what type is x?" really means "what type is the value in x's slot right now?" — which is exactly what typeof answers.

JavaScript
let x = 10;    // number
x = "Hello";   // now a string — perfectly legal
console.log(typeof x); // "string"
What’s happening
  1. let x = 10 creates a binding x and puts the number 10 in it. Nothing records "x is a number" — only the value 10 carries the type number.
  2. x = "Hello" replaces the value in the same binding with a string. There's no type check to fail, so this is legal.
  3. typeof x inspects the current value, not the declaration, so it reports "string". A minute earlier the same expression would have returned "number".
  4. This flexibility is why TypeScript exists: it adds the "this variable must stay a number" check at compile time, then erases it — at runtime it's still plain dynamic JavaScript.
AdvancedThe 7 primitives and their typeof

There are 7 primitive types and everything else is an object:

PrimitiveExampletypeof
string"hi""string"
number42, 3.14, NaN"number"
bigint10n"bigint"
booleantrue"boolean"
undefinedundefined"undefined"
nullnull"object" ⚠
symbolSymbol("id")"symbol"

"Everything else" means plain objects, arrays, functions, dates, maps, class instances, regular expressions — all of them are objects, and all of them can have properties added and changed. Primitives can't: "abc".toUpperCase() returns a new string and leaves the original untouched.

Primitives are immutable and copied by value. Objects (including arrays and functions) are copied by reference — two variables can point at the same object.

JavaScript
let a = 1;
let b = a;   // copy of the value
b++;
console.log(a); // 1

const o1 = { n: 1 };
const o2 = o1;  // copy of the reference
o2.n++;
console.log(o1.n); // 2 — same object
What’s happening
  1. let b = a copies the value 1 into b's own slot. From now on a and b have nothing to do with each other.
  2. b++ changes b from 1 → 2. a still holds its own 1, so console.log(a) prints 1.
  3. const o1 = { n: 1 } creates one object somewhere in memory and stores a reference (think: an address) to it in o1.
  4. const o2 = o1 copies that reference, not the object. There is still only one object, and now two labels point at it.
  5. o2.n++ follows the reference and changes the object's n from 1 → 2. Reading o1.n follows the same reference to the same object, so it prints 2.
  6. Note const didn't stop this: const protects the binding o2 (you can't point it at a different object), not the object it points at.
AdvancedPassing objects: reassign vs mutate

The same rule applies when you pass arguments to a function. JavaScript always passes a copy of whatever is in the variable — for an object, that's a copy of the reference. People call this "pass by sharing":

JavaScript
function reassign(obj) {
  obj = { n: 99 };   // points the local parameter somewhere else
}
function mutate(obj) {
  obj.n = 99;        // changes the shared object
}

const box = { n: 1 };
reassign(box);
console.log(box.n); // 1
mutate(box);
console.log(box.n); // 99
What’s happening
  1. reassign(box) copies the reference from box into the parameter obj. Both now point at the object { n: 1 }.
  2. Inside, obj = { n: 99 } makes the local obj point at a brand-new object. box is a different variable and still points at the original, so box.n is still 1.
  3. mutate(box) again copies the reference into obj. This time obj.n = 99 goes through the reference and changes the one shared object: n goes 1 → 99.
  4. box.n reads that same object, so it prints 99.
  5. This is the interview answer to "is JavaScript pass-by-reference?": no — it's pass-by-value, but for objects the value is a reference. You can mutate the caller's object, but you can't make the caller's variable point somewhere else.
AdvancedEdge cases: typeof null, functions, arrays

#var, let and const

All three declare variables; they differ in where the variable is visible (scope), what happens before the declaration line runs (hoisting) and whether you can assign it again. var is the original from 1995. let and const arrived in ES2015 to fix its two biggest problems: it ignores block boundaries, and it can be used — silently as undefined — before it's declared.

varletconst
ScopeFunctionBlock { }Block { }
HoistedYes, initialised to undefinedYes, but in the TDZYes, but in the TDZ
Re-declare in same scopeAllowedSyntaxErrorSyntaxError
Re-assignAllowedAllowedTypeError
Creates window property at top levelYesNoNo

A few rows deserve a sentence each:

  • Re-declare is a SyntaxError, which means the engine rejects the whole script while parsing it — not one line of it runs. var x; var x; is fine and quietly reuses the same variable, which is how accidental clashes went unnoticed in big files.
  • Re-assigning a const is a TypeError at runtime: Assignment to constant variable. The code up to that line has already run.
  • The window property row applies to classic <script> files only. A top-level var foo there becomes window.foo, visible to every other script on the page. Top-level let/const go into a shared script scope instead, and inside ES modules nothing is global at all.
JavaScript
function scopes() {
  if (true) {
    var a = 1;
    let b = 2;
    const c = 3;
  }
  console.log(a); // 1  — var ignores the block
  console.log(b); // ReferenceError: b is not defined
}
What’s happening
  1. Before scopes runs any code, the engine sets up its scope. var a belongs to the whole function, so a exists from the first line with the value undefined.
  2. The if block creates a new block scope. let b and const c live only inside it.
  3. Inside the block, a = 1 assigns to the function-level a; b and c are created with 2 and 3.
  4. The block ends. b and c go out of scope and are gone; a is untouched because it was never block-scoped.
  5. console.log(a) prints 1. console.log(b) looks up the chain for a b, finds none in the function or the global scope, and throws ReferenceError: b is not defined. Execution stops there, so a console.log(c) after it would never run.
  6. If you changed var a to let a, the first console.log would throw instead — which is the behaviour you usually want: a variable declared in a block shouldn't leak out of it.
AdvancedPitfall: mutating a const object
AdvancedChoosing between const, let and var

#Hoisting and the temporal dead zone

Hoisting: before any code runs, the engine registers every declaration in a scope. Declarations are "moved to the top"; initialisations are not.

Nothing actually moves. What really happens is that the engine processes a scope in two passes. In the creation phase it scans the scope and creates a binding for every declaration it finds: var bindings start as undefined, function declarations are bound to the whole function, and let/const/class bindings are created but left uninitialised. Then the execution phase runs the code top to bottom, and assignments happen when their line is reached. "Hoisting" is just the visible effect of that first pass.

JavaScript
console.log(x); // undefined — declaration hoisted, value not
var x = 5;

hoisted(); // works: function declarations are hoisted with their body
function hoisted() {
  console.log("I am hoisted");
}
What’s happening
  1. Creation phase: the engine finds var x and creates x = undefined. It finds function hoisted and binds hoisted to the complete function object. No line has run yet.
  2. Execution starts. console.log(x) finds the binding x, which exists but still holds undefined — so it prints undefined instead of throwing.
  3. var x = 5 now runs only its assignment part: x goes undefined → 5. The declaration part was already handled in step 1.
  4. hoisted() is called on a line above its definition, but hoisted was fully set up in step 1, so it runs and logs "I am hoisted".
  5. This is why you can put helper function declarations at the bottom of a file and call them from the top — a common, deliberate style.
AdvancedHoisting of function expressions

Function expressions don't get this treatment. Only the variable they're assigned to is hoisted, following that variable's rules:

JavaScript
notHoisted(); // TypeError: notHoisted is not a function
var notHoisted = function () {};
What’s happening
  1. Creation phase: var notHoisted is registered as undefined. The function on the right-hand side doesn't exist yet — it's created only when that line runs.
  2. notHoisted() finds the binding, reads undefined, and tries to call it. Calling undefined throws TypeError: notHoisted is not a function.
  3. Note it's a TypeError, not a ReferenceError: the name exists, its value just isn't callable.
  4. With const notHoisted = … you'd get a ReferenceError instead (Cannot access 'notHoisted' before initialization), because of the TDZ described next.
Advancedlet/const and the temporal dead zone

let and const are hoisted too, but they sit in the temporal dead zone (TDZ) from the start of the block until the line that declares them. Touching them there throws.

JavaScript
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5;
What’s happening
  1. Creation phase: the engine sees let y and creates the binding y, but marks it uninitialised instead of setting it to undefined.
  2. console.log(y) finds the binding — which is why the message says "before initialization" rather than "y is not defined" — but reading an uninitialised binding is forbidden, so it throws.
  3. Had execution reached let y = 5, the binding would have been initialised to 5 and been usable from then on.
  4. The TDZ is "temporal" because it's about time, not position: a function written above let y may read y safely as long as it's called after the let y line has run.
  5. Classes behave the same way: new K() before class K {} throws Cannot access 'K' before initialization. Even typeof y throws inside the TDZ — the one case where typeof isn't safe.
AdvancedWhy the TDZ exists

Why have a TDZ at all? With var, reading a variable too early gives you a silent undefined that flows on through your code and causes a confusing bug somewhere else. The TDZ turns that mistake into an immediate, clearly named error at the line that caused it.

Predict the output
JavaScript
let x = 10;
function test() {
  console.log(x);
  let x = 20;
}
test();
Show answer

ReferenceError: Cannot access 'x' before initialization.

The inner let x is hoisted to the top of test, so every x inside test refers to the inner one — which is still in the TDZ when console.log runs. The outer x = 10 is shadowed for the whole function, not just after line 3.

What’s happening
  1. The global let x = 10 runs, so the outer x holds 10.
  2. test() is called. Its creation phase registers the inner let x as an uninitialised binding in test's scope — before console.log has run.
  3. console.log(x) looks up x. The lookup starts in the nearest scope, finds the inner x in test straight away, and stops there. It never reaches the outer 10.
  4. The inner x is still in the TDZ (its let line hasn't run), so the read throws. let x = 20 is never reached.
  5. If you changed the inner let to var, the output would be undefined: the inner x would still shadow the outer one, but it would start as undefined instead of throwing. Delete the inner declaration altogether and it prints 10.

#Variable shadowing

An inner scope can declare a variable with the same name as an outer one; the inner one shadows the outer one inside its block. They are two separate variables that happen to share a name. Name lookup always starts in the innermost scope and walks outwards, stopping at the first match — so inside the block, the outer variable is simply unreachable by that name.

JavaScript
let x = 10;
{
  let x = 20;     // a different variable
  console.log(x); // 20
}
console.log(x);   // 10 — block scope keeps them apart
What’s happening
  1. The outer let x = 10 creates variable #1 in the outer scope.
  2. The { } block opens a new scope, and let x = 20 creates variable #2 inside it. Nothing is overwritten — variable #1 still holds 10.
  3. console.log(x) inside the block looks in the block first, finds #2, and prints 20.
  4. The block ends and #2 disappears. The last console.log(x) can only see #1, so it prints 10.
  5. If the inner line were x = 20 with no let, there'd be no second variable: the assignment would walk out to #1 and change it, and both logs would print 20.
AdvancedDeliberate shadowing and ESLint no-shadow

Shadowing is legal and sometimes deliberate (a callback parameter named error inside a function that also has an error), but it makes code harder to read. ESLint's no-shadow rule flags it for that reason.

AdvancedCorrecting my notes: self-referencing let
AdvancedIllegal shadowing: let with var

Illegal shadowing: you can shadow var with let in an inner block, but not let with var (because var would leak out of the block and clash).

JavaScript
let a = 1;
{
  var a = 2; // SyntaxError: Identifier 'a' has already been declared
}
What’s happening
  1. var ignores blocks, so var a inside { } really declares a in the enclosing scope — the same scope that already contains let a.
  2. That's two declarations of a in one scope, and one of them is let. Re-declaring a let binding is never allowed, so the engine reports Identifier 'a' has already been declared.
  3. Because it's a SyntaxError, it's caught while parsing: not even let a = 1 runs.
  4. The reverse is fine: var a = 1; { let a = 2; } puts the let in the block's own scope, so the two never collide.

#Equality: == vs ===

  • === strict equality — no type conversion. Same type and same value.
  • == loose equality — converts the operands to a common type first, then compares.

Loose equality follows a fixed algorithm, and knowing its order lets you predict any result. If the types already match, it behaves exactly like ===. Otherwise: null and undefined equal each other and nothing else; a boolean is converted to a number (true → 1, false → 0); a string compared with a number is converted to a number; and an object compared with a primitive is converted to a primitive (via valueOf, then toString). It repeats until both sides have the same type. For objects on both sides, both operators just compare references — two different objects are never equal, however similar.

JavaScript
1 == "1";            // true  ("1" → 1)
0 == false;          // true  (false → 0)
"" == 0;             // true  ("" → 0)
null == undefined;   // true  (special rule)
null == 0;           // false (null only == undefined)

1 === "1";           // false — different types
0 === false;         // false
null === undefined;  // false
What’s happening
  1. 1 == "1": number vs string, so the string is converted with Number("1") → 1. Then 1 == 1 → true.
  2. 0 == false: the boolean is converted first, false → 0, leaving 0 == 0 → true.
  3. "" == 0: Number("") is 0 (an empty or whitespace-only string converts to 0), so 0 == 0 → true. This is the one that bites with form inputs.
  4. null == undefined is hard-coded to true. And null == 0 is false because null is not converted to a number for == — it only loosely equals undefined (and itself). That's surprising, because null >= 0 is true: the relational operators do convert null to 0.
  5. The === lines never convert anything. Different types means false immediately, which is why all three are false.
AdvancedEdge cases with NaN and -0

Edge cases worth knowing:

JavaScript
NaN === NaN;               // false — NaN is not equal to anything
Number.isNaN(NaN);         // true
Object.is(NaN, NaN);       // true
Object.is(0, -0);          // false (=== says true)
[] == ![];                 // true 🙃  ![] → false → 0, [] → "" → 0
What’s happening
  1. NaN === NaN is false by IEEE 754 floating-point rules: NaN means "not a valid number", and two invalid results aren't considered the same. So x !== x is true only when x is NaN.
  2. Number.isNaN(NaN) asks the question directly. Prefer it to the old global isNaN, which converts first — isNaN("abc") is true because "abc" becomes NaN.
  3. Object.is is SameValue equality: like ===, except NaN equals NaN and 0 does not equal -0. React uses it to compare state and dependency arrays.
  4. [] == ![]: ! runs first and any object is truthy, so ![] is false. Now it's [] == false. The boolean becomes 0: [] == 0. The array becomes a primitive: [].toString() is "", giving "" == 0. Finally Number("") is 0, and 0 == 0 → true.
  5. Different built-ins use different equality: [NaN].indexOf(NaN) is -1 (uses ===) but [NaN].includes(NaN) is true (uses SameValueZero — like Object.is, but 0 and -0 count as equal). Set and Map keys use SameValueZero too.
AdvancedThe == null exception

#Truthy and falsy values

In a boolean context every value becomes true or false. A "boolean context" is anywhere JavaScript needs a yes/no answer: if, while, the ternary ? :, !, and the left side of && and ||. The engine converts the value with the same rules as Boolean(value). There are exactly 8 falsy values; everything else is truthy.

JavaScript
false, 0, -0, 0n, "", null, undefined, NaN
AdvancedEvery falsy value, one by one

Taking them one by one: false itself; the number zero in both its signs, 0 and -0; the BigInt zero 0n; the empty string "" (a string containing only a space, " ", is truthy); the two "no value" values null and undefined; and NaN, the failed number. The rule of thumb behind the list: zero, empty and missing are falsy. Every object is truthy — even an empty one, and even new Boolean(false). (The one historical oddity is the browser's document.all, which is deliberately falsy for old-code compatibility.)

JavaScript
const items = [];
if (items) console.log("runs — [] is truthy");
if (items.length) console.log("never runs");

const qty = "0";            // e.g. from an <input>
if (qty) console.log("runs — non-empty string");
if (Number(qty)) console.log("never runs");
What’s happening
  1. items is an empty array. It's an object, and all objects are truthy, so the first if runs even though the array has nothing in it.
  2. items.length is 0, which is falsy, so the second if correctly skips. That's why "is the list empty?" should always be checked through .length.
  3. qty is the string "0" — what an input field gives you. It's a non-empty string, so it's truthy and the third if runs, despite looking like zero.
  4. Number(qty) converts it to the number 0, which is falsy, so the last if skips. Convert user input to the type you mean before testing it.
AdvancedPitfall: empty arrays and "0" are truthy

#Optional chaining ?. and nullish coalescing ??

Both operators (ES2020) exist for the same reason: in real data, values are often missing, and JavaScript represents "missing" with null or undefined. Before ES2020 you guarded against that with && chains and || defaults, and both of those treat every falsy value as missing — which is wrong for 0, "" and false. The new operators look only at null and undefined, which together are called nullish.

Optional chaining reads deeply nested properties safely: if anything before ?. is null or undefined, the whole expression short-circuits to undefined instead of throwing.

JavaScript
const user = { name: "Alice", address: { city: "Wonderland" } };

// Old way
const city1 = user && user.address && user.address.city;

// With optional chaining
const city2 = user?.address?.city;      // "Wonderland"
const zip = user?.address?.zip?.code;   // undefined, no error
What’s happening
  1. The old way relies on && returning its first falsy operand or its last operand. user is truthy, user.address is truthy, so it returns user.address.city → "Wonderland". It works, but it repeats the path and would also stop on an empty string or 0.
  2. user?.address?.city: user isn't nullish, so .address is read; that isn't nullish, so .city is read → "Wonderland".
  3. user?.address?.zip?.code: user.address.zip is undefined. The ?. before code sees that and short-circuits: the whole expression becomes undefined without reading .code.
  4. Without that last ?., user.address.zip.code would throw TypeError: Cannot read properties of undefined (reading 'code').
  5. The short-circuit covers the rest of the chain: in user?.address.city, if user were null, .address.city would be skipped entirely — no need for ?. on every link. But parentheses end the chain, so (null?.a).b still throws.
Advanced?. with dynamic keys and method calls

It also works with dynamic keys and method calls:

JavaScript
const key = "city";
user.address?.[key];        // dynamic property
user.greet?.();             // call only if greet exists
callbacks.onDone?.(result); // common for optional callbacks
What’s happening
  1. user.address?.[key] is the bracket version: if user.address is nullish it returns undefined; otherwise it reads user.address["city"] → "Wonderland".
  2. user.greet?.() checks whether user.greet is nullish before calling. Our user has no greet, so the call is skipped and the result is undefined. When a call is skipped, its arguments aren't evaluated either.
  3. ?.() only protects against null/undefined. If greet were a string, the call would still throw TypeError: user.greet is not a function.
  4. callbacks.onDone?.(result) is the everyday use: a component or library accepts an optional callback and calls it only if the caller supplied one, replacing if (callbacks.onDone) callbacks.onDone(result).
Advanced?? vs || with falsy values

Nullish coalescing returns the right-hand side only when the left is null or undefined — unlike ||, which falls back on any falsy value.

JavaScript
const count = 0;
count || 10;  // 10  — 0 is falsy, oops
count ?? 10;  // 0   — 0 is a real value, keep it

const input = null;
const finalInput = input ?? "Default Value"; // "Default Value"
What’s happening
  1. count || 10 asks "is count truthy?". 0 is falsy, so || returns the right side, 10 — throwing away a perfectly valid zero. This is the classic bug with settings like timeout: 0 or volume: 0.
  2. count ?? 10 asks only "is count null or undefined?". It's neither, so ?? returns count itself → 0.
  3. input ?? "Default Value": input is null, so the default is used.
  4. Both operators short-circuit: the right side is evaluated only if it's needed, so a ?? expensiveDefault() calls the function only when a is nullish.
  5. Rule of thumb: use ?? for defaults ("use this when no value was given"); use || only when you genuinely want to replace every falsy value, such as turning an empty string into "Anonymous".
AdvancedLogical assignment: ??=, ||=, &&=

Logical assignment operators combine both ideas:

JavaScript
config.retries ??= 3;    // assign only if null/undefined
user.name ||= "Guest";   // assign if falsy
cache.hits &&= cache.hits + 1; // assign only if truthy
What’s happening
  1. config.retries ??= 3: if retries is missing (nullish), it becomes 3. If it's already 0 or 5, it's left alone. Run it twice and the second run does nothing.
  2. user.name ||= "Guest": if name is falsy — missing, or an empty string — it becomes "Guest".
  3. cache.hits &&= cache.hits + 1: only if hits is truthy does it become hits + 1. Watch the edge: if hits starts at 0, it's falsy, so it stays 0 forever. &&= is for "transform it if it's there", e.g. user.name &&= user.name.trim().
  4. Each operator short-circuits the assignment: a ??= b means a ?? (a = b), not a = a ?? b. When no assignment is needed, no write happens at all — so no setter runs, and a read-only property doesn't throw.
AdvancedNo assignment through ?.

#Undeclared variables and strict mode

Assigning to a name that was never declared with var, let or const silently creates a global variable in sloppy mode. In strict mode it throws.

"Sloppy mode" is the informal name for JavaScript's original, forgiving behaviour. Strict mode (ES5, 2009) is an opt-in variant that turns a set of silent mistakes into errors. It couldn't simply change the default, because that would have broken existing websites — so it's opt-in, and newer syntax (modules, classes) opts in automatically.

JavaScript
function foo() {
  x = 1; // sloppy mode: creates global x. Strict mode: ReferenceError
}
foo();
console.log(x); // 1
What’s happening
  1. foo() runs x = 1. The engine looks for a binding called x in foo's scope, then the global scope, and finds none.
  2. In sloppy mode, instead of failing, it creates a property x on the global object (window in a browser, globalThis everywhere). So a typo like cuont = 1 quietly creates a new global.
  3. console.log(x) finds that global property and prints 1.
  4. In strict mode, step 2 throws ReferenceError: x is not defined inside foo, so the console.log line is never reached — the typo is caught at the exact line where it happened.
AdvancedUndeclared, undefined, null and strict mode
ValuetypeofAccessing it
Undeclared—"undefined"ReferenceError
Declared, not assignedundefined"undefined"undefined
nullintentional "no value""object"null

Reading the table row by row: an undeclared name has no binding at all, so reading it throws — yet typeof on it returns "undefined" instead of throwing, which is what makes typeof the safe feature-detection tool. A declared but unassigned variable has a binding holding undefined. And null is a value you assign on purpose to say "deliberately empty"; by convention the engine produces undefined (missing property, missing argument, no return), while your code produces null.

Turn on strict mode with "use strict"; at the top of a file or function. ES modules and classes are always strict, so in modern code you get it for free. Besides the undeclared-variable check, strict mode makes writes to read-only properties throw (see freeze vs seal), makes this undefined instead of the global object in plain function calls, and forbids duplicate parameter names and with.

#Numbers: int or float?

Added

JavaScript has one number type for both integers and decimals: every number is a 64-bit IEEE 754 floating-point value (a "double"). There's no separate int, so "is this an integer?" really means "does this number have no fractional part?". That single representation also explains the famous rounding errors and the limit on safe integers below.

My notes said to check x % 1 === 0. That works, but there's a built-in that also handles the edge cases:

JavaScript
Number.isInteger(5);      // true
Number.isInteger(5.0);    // true — 5.0 is the same number as 5
Number.isInteger(5.5);    // false
Number.isInteger("5");    // false — no coercion
Number.isInteger(Infinity); // false (but Infinity % 1 is NaN, so the % trick is also fine)

const isFloat = (n) => Number.isFinite(n) && !Number.isInteger(n);
What’s happening
  1. 5 and 5.0 are not two values of different types — they're two ways of writing the identical double, so both are integers. 5 === 5.0 is true.
  2. 5.5 has a fractional part → false. The % approach agrees: 5.5 % 1 is 0.5, not 0.
  3. Number.isInteger("5") is false because it doesn't convert: anything that isn't of type number fails. The % trick would convert — "5" % 1 is 0 — and wrongly say yes.
  4. Infinity isn't an integer. Infinity % 1 is NaN, and NaN === 0 is false, so both approaches agree here.
  5. isFloat needs both checks: Number.isFinite (no conversion either) rules out NaN, Infinity and non-numbers, then !Number.isInteger keeps only values with a fractional part. isFloat(5.5) is true; isFloat(5) and isFloat(NaN) are false.
AdvancedFloating point and other number facts

Other number facts interviewers like:

JavaScript
0.1 + 0.2 === 0.3;                 // false (0.30000000000000004)
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true — compare with a tolerance
Number.MAX_SAFE_INTEGER;           // 9007199254740991 (2^53 - 1)
2 ** 53 === 2 ** 53 + 1;           // true! use BigInt beyond this
9007199254740993n + 1n;            // 9007199254740994n
parseInt("08px");                  // 8 — parses until it can't
Number("08px");                    // NaN — all or nothing
What’s happening
  1. 0.1 and 0.2 can't be stored exactly in binary, just as 1/3 can't be written exactly in decimal. Each is stored as the nearest double (0.1 is really 0.1000000000000000055…), and the tiny errors add up to 0.30000000000000004, which isn't the double nearest 0.3.
  2. The fix is to compare with a tolerance. Number.EPSILON (about 2.2e-16) is the gap between 1 and the next representable double, so it works as a tolerance for values around 1. For bigger numbers scale the tolerance — 1000.1 + 1000.2 differs from 2000.3 by more than EPSILON. For money, avoid the problem by working in integer cents.
  3. A double has 53 bits of precision for the digits, so every integer up to 2^53 - 1 (9007199254740991) is stored exactly. That's Number.MAX_SAFE_INTEGER.
  4. Beyond it, not every integer has a representation. 2 ** 53 + 1 can't be stored, so it rounds to 2 ** 53, and the comparison says true. Large database IDs arriving as JSON numbers get silently corrupted this way, which is why APIs send them as strings.
  5. BigInt (the n suffix) stores integers of any size exactly, so 9007199254740993n + 1n is exactly 9007199254740994n. You can't mix it with ordinary numbers: 1n + 1 is a TypeError.
  6. parseInt("08px") reads characters left to right and stops at the first one that isn't a digit, returning 8. Number("08px") requires the whole string (ignoring surrounding whitespace) to be a valid number, so it returns NaN. Use parseInt for "12px"-style strings and Number for validating input.

#Type coercion cheat sheet

Added

Coercion is JavaScript converting a value to another type automatically because an operator needs it. It's driven by the operator, not the value: + might want strings or numbers, - always wants numbers, if wants a boolean. When an operator needs a primitive and gets an object, it calls the object's valueOf() first and, if that doesn't return a primitive (for plain objects and arrays it returns the object itself), falls back to toString(). Knowing that one rule decodes every line below.

JavaScript
"5" + 3;        // "53"   + with a string concatenates
"5" - 3;        // 2      other maths operators convert to number
"5" * "2";      // 10
true + 1;       // 2
[] + [];        // ""
[] + {};        // "[object Object]"
+"";            // 0
+"  42  ";      // 42
+"4 2";         // NaN
String(null);   // "null"
Number(null);   // 0
Number(undefined); // NaN
What’s happening
  1. "5" + 3: one operand is a string, so + concatenates — 3 becomes "3", giving "53". "5" - 3: - only does maths, so "5" becomes 5 → 2. "5" * "2" converts both → 10.
  2. true + 1: no strings involved, so both become numbers, true → 1, giving 2.
  3. [] + []: arrays aren't primitives, so each is converted. valueOf() returns the array itself, so toString() is used — an empty array joins to "". "" + "" is "".
  4. [] + {}: [] becomes "" and the plain object's toString() gives "[object Object]". One side is now a string, so they concatenate → "[object Object]". (Typing {} + [] at the start of a line in a console gives 0: the {} is parsed as an empty block, leaving just +[].)
  5. Unary + is shorthand for Number(...). An empty string is 0; surrounding whitespace is trimmed, so " 42 " is 42; but a space inside the digits makes the whole string invalid → NaN.
  6. String(null) is "null". Number(null) is 0 but Number(undefined) is NaN — so null + 1 is 1 while undefined + 1 is NaN.

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.