{} block, while var is function-scoped and hoisted to the top of its enclosing function initialized as undefined. Both have a Temporal Dead Zone (TDZ) — accessing them before the declaration line throws a ReferenceError — and const prevents reassignment of the binding, though object mutation is still allowed.
// Block scoping — let/const don't leak out
{ let a = 1; const b = 2; var c = 3; }
// console.log(a); // ReferenceError — block-scoped
// console.log(b); // ReferenceError — block-scoped
console.log(c); // 3 — var leaks out of block
// TDZ: var is hoisted + initialized, let/const are NOT
console.log(x); // undefined (var hoisted)
var x = 5;
// console.log(y); // ReferenceError (TDZ)
let y = 5;
// const prevents rebinding, not mutation
const arr = [1, 2, 3];
arr.push(4); // OK — mutation allowed
// arr = []; // TypeError — rebinding blocked
Why it matters: TDZ behavior and hoisting differences are among the most-asked ES6 questions in interviews; defaulting to const and using let only when reassignment is needed signals modern, disciplined JavaScript.
Real applications: React hook calls use const exclusively since hook references must never be reassigned; Angular's style guide mandates const for injected services to prevent accidental rebinding.
Common mistakes: Assuming const makes objects immutable — it only prevents reassigning the variable binding itself; use Object.freeze() for shallow immutability of an object's own properties.
${expression}, native multi-line strings without escape characters, and tagged templates where a prefix function receives static string segments and interpolated values to build custom DSLs. Tagged templates are evaluated at call time with the raw string array carrying an unprocessed .raw property for escape sequences.
const user = "Alice";
const score = 42;
// Interpolation & multi-line
const card = `
<div class="card">
<h2>${user}</h2>
<p>Score: ${score}</p>
</div>
`;
// Tagged template — sanitize user input
function safe(strings, ...vals) {
return strings.reduce((out, str, i) => {
const escaped = String(vals[i - 1] ?? "")
.replace(/</g, "<").replace(/>/g, ">");
return out + escaped + str;
});
}
const input = "<script>xss</script>";
safe`User: ${input}`; // "User: <script>xss</script>"
Why it matters: Template literals appear in virtually every modern codebase; tagged templates power industry-defining libraries and interviewers at senior levels expect fluency with both.
Real applications: styled-components uses tagged templates to write scoped CSS inside React components; Apollo GraphQL uses the gql`...` tag to parse and cache query ASTs at module load time.
Common mistakes: Embedding unsanitized user input directly in a template literal used for HTML construction — this opens XSS vulnerabilities; always sanitize via a tagged template or a dedicated escaping library.
.... It works in variable declarations, assignment expressions, and function parameters, eliminating repetitive property access chains.
// Array destructuring with skip, rest, and defaults
const [first, , third = 0, ...tail] = [10, 20, 30, 40];
// first=10, third=30, tail=[40]
// Object destructuring with rename and nested default
const { name: user, address: { city } = {} } = {
name: "Alice", address: { city: "NYC" }
};
// user="Alice", city="NYC"
// Function parameter destructuring
function connect({ host = "localhost", port = 5432 } = {}) {
return `${host}:${port}`;
}
connect({ port: 3306 }); // "localhost:3306"
// Variable swap without temp
let a = 1, b = 2;
[a, b] = [b, a]; // a=2, b=1
Why it matters: Destructuring is ubiquitous in React hooks, Redux reducers, and ES module imports; code that avoids it is immediately noticeable as pre-ES6 style to any interviewer.
Real applications: React uses array destructuring for every useState call; Node.js/Express route handlers destructure req.body and req.params inline for self-documenting code.
Common mistakes: Destructuring a property that resolves to null rather than undefined — null does not trigger default values and nested access on it throws a TypeError.
...) expands any iterable — arrays, strings, Sets — into individual elements and performs a shallow merge of enumerable own properties in object literals, with later keys overwriting earlier ones. It is the idiomatic ES6 way to create copies, merge collections, and pass variadic arguments without mutating the originals.
// Array copy and merge
const nums = [1, 2, 3];
const copy = [...nums]; // [1, 2, 3] — new reference
const merged = [...nums, 4, 5]; // [1, 2, 3, 4, 5]
// Object shallow merge (right-side keys win)
const defaults = { theme: "light", lang: "en" };
const overrides = { lang: "fr", size: 14 };
const config = { ...defaults, ...overrides };
// { theme: "light", lang: "fr", size: 14 }
// Spread as function arguments
Math.max(...nums); // 3
// Deduplicate array via Set
const unique = [...new Set([1, 2, 2, 3, 3])]; // [1, 2, 3]
// Shallow copy trap
const obj = { a: { b: 1 } };
const copy2 = { ...obj };
copy2.a.b = 99;
console.log(obj.a.b); // 99 — same nested reference!
Why it matters: Immutable update patterns using spread are foundational to Redux reducers and React state; the shallow vs deep copy distinction is a classic interview trap.
Real applications: Redux reducers return { ...state, count: state.count + 1 } to produce new state objects; React uses <Child {...props} /> JSX syntax to forward all props via spread.
Common mistakes: Assuming spread creates a deep copy — nested objects and arrays share the same reference, so mutating a nested property in the copy also mutates the original.
size property, unlike plain objects which coerce all keys to strings. Set stores only unique values of any type using SameValueZero equality (like === but treating NaN equal to itself), making it perfect for deduplication and O(1) membership checks.
// Map — any key type, ordered, accurate size
const map = new Map();
const objKey = { id: 1 };
map.set(objKey, "user data");
map.set(42, "number key");
console.log(map.get(objKey)); // "user data"
console.log(map.size); // 2
// Iterate entries
for (const [k, v] of map) console.log(k, v);
// Convert object <-> Map
const m = new Map(Object.entries({ a: 1, b: 2 }));
Object.fromEntries(m); // { a: 1, b: 2 }
// Set — unique values, fast membership
const set = new Set([1, 2, 2, 3, NaN, NaN]);
console.log([...set]); // [1, 2, 3, NaN]
const unique = [...new Set(["a","b","a"])]; // ["a","b"]
const forbidden = new Set(["admin", "root"]);
forbidden.has("admin"); // true — O(1)
Why it matters: Choosing Map over plain objects for keyed lookups and Set for membership tests demonstrates understanding of data structure complexity — a frequent topic at mid-to-senior level interviews.
Real applications: React's reconciler uses Maps internally to track fiber nodes; Lodash's _.uniq uses Set semantics under the hood for O(n) deduplication in modern environments.
Common mistakes: Using a plain object as a lookup cache when keys are dynamic numbers — objects silently stringify numeric keys, losing type fidelity that Map preserves.
size property, which is intentional — it enables leak-free metadata association without preventing GC.
// WeakMap — associate metadata without preventing GC
const cache = new WeakMap();
function processUser(user) {
if (cache.has(user)) return cache.get(user);
const result = expensiveCompute(user);
cache.set(user, result);
return result;
}
// When user goes out of scope, cache entry is freed automatically
// WeakSet — track visited objects without memory leaks
const visited = new WeakSet();
function traverse(node) {
if (visited.has(node)) return; // cycle guard
visited.add(node);
for (const child of node.children ?? []) traverse(child);
}
// Private data pattern (pre-# fields)
const _priv = new WeakMap();
class Counter {
constructor() { _priv.set(this, { count: 0 }); }
increment() { _priv.get(this).count++; }
get value() { return _priv.get(this).count; }
}
Why it matters: Memory leaks from retaining object references are a real production issue; WeakMap/WeakSet show interviewers you understand garbage collection and write leak-proof code.
Real applications: Vue 3's reactivity system uses WeakMap to associate reactive metadata with objects so they are freed when components unmount; React's internal fiber tree uses similar weak-reference patterns.
Common mistakes: Trying to iterate a WeakMap or read its size — this is intentionally impossible; use a regular Map if enumeration or counting is needed.
Symbol.iterator protocol, while for...in iterates the string-keyed enumerable properties of an object including those inherited through the prototype chain. Using for...in on arrays is a known footgun because it yields string indices and may include prototype extensions.
const arr = ["a", "b", "c"];
for (const val of arr) console.log(val); // "a" "b" "c" (values)
for (const key in arr) console.log(key); // "0" "1" "2" (string keys)
// for...of with Map gives [key, value] pairs
const map = new Map([["x", 1], ["y", 2]]);
for (const [key, val] of map) console.log(key, val); // x 1 / y 2
// for...in includes inherited prototype props — dangerous!
function Animal(name) { this.name = name; }
Animal.prototype.type = "creature";
const dog = new Animal("Rex");
for (const k in dog) console.log(k); // "name", "type" (inherited!)
// Safe alternative for own properties only
for (const k of Object.keys(dog)) console.log(k); // "name" only
Why it matters: Misusing for...in on arrays is a classic source of bugs; interviewers test whether you know which iteration construct is correct for each data structure.
Real applications: Node.js readable streams use for await...of (async for...of) to consume chunks; Angular's *ngFor maps directly to for...of semantics when iterating template lists.
Common mistakes: Using for...in to iterate an array — it yields string index keys, not values, and if any library adds to Array.prototype those names appear in the loop too.
Symbol("id") !== Symbol("id") — making symbols ideal as property keys that never collide with string keys or other symbols. Well-known Symbols are built-in constants on the Symbol object (Symbol.iterator, Symbol.toPrimitive, Symbol.hasInstance) that let you hook into language-level protocols and customize object behavior.
// Unique keys — no collision with string keys
const ID = Symbol("id");
const obj = { [ID]: 123, id: "string-id" };
console.log(obj[ID]); // 123 — symbol key
console.log(obj.id); // "string-id" — string key
// Well-known Symbol.iterator — make object iterable
const range = {
from: 1, to: 5,
[Symbol.iterator]() {
let cur = this.from, last = this.to;
return { next() {
return cur <= last ? { value: cur++, done: false } : { done: true };
}};
}
};
console.log([...range]); // [1, 2, 3, 4, 5]
// Global registry — share symbols across modules
const s1 = Symbol.for("app.token");
const s2 = Symbol.for("app.token");
console.log(s1 === s2); // true
Why it matters: Well-known Symbols are how JavaScript exposes its core protocols for extension — for...of, spread, and instanceof all ultimately depend on them, making this a senior-level differentiator.
Real applications: Immutable.js implements Symbol.iterator on its collections so they work with for...of and spread; RxJS Observables implement Symbol.observable for cross-library interoperability.
Common mistakes: Using Symbol() when a shared symbol is needed — each call creates a new unique value; use Symbol.for(key) to retrieve or create the same symbol across different modules.
?.) short-circuits and returns undefined instead of throwing a TypeError when the value to the left is null or undefined. It works with property access (obj?.prop), method calls (obj?.method()), and bracket notation (obj?.[expr]), eliminating verbose null-guard chains when consuming optional API data or deeply nested configuration.
const user = {
name: "Alice",
address: { city: "NYC" },
getTier() { return "premium"; }
};
console.log(user?.address?.city); // "NYC"
console.log(user?.phone?.number); // undefined — no error
console.log(user?.getTier()); // "premium"
console.log(user?.subscribe?.()); // undefined — method doesn't exist
console.log(user?.tags?.[0]); // undefined — bracket notation
// Combine with ?? for safe defaults
const city = user?.address?.city ?? "Unknown"; // "NYC"
const zip = user?.address?.zip ?? "N/A"; // "N/A"
// Short-circuit — side effects after ?. don't run
let count = 0;
null?.doSomething(count++);
console.log(count); // 0 — never executed
Why it matters: Optional chaining prevents the most common runtime crash in frontend code (TypeError: Cannot read properties of undefined); it is essential when consuming uncertain API responses or user-driven data.
Real applications: React components use props.user?.profile?.avatar for optional data rendering; TypeScript narrows types through optional chaining and pairs it with ?? for safe default fallbacks.
Common mistakes: Over-applying ?. on properties that should always exist — hiding missing required data silences errors and makes bugs much harder to track down in production.
??) returns the right-hand operand only when the left is strictly null or undefined, making it the correct default-value operator when 0, false, or "" are valid intended values. This contrasts with ||, which falls back on any falsy value and can accidentally override legitimate zeros and empty strings.
const qty = 0;
const name = "";
// ?? — only null/undefined triggers fallback
console.log(qty ?? "default"); // 0 — kept!
console.log(name ?? "default"); // "" — kept!
// || — any falsy triggers fallback (often wrong)
console.log(qty || "default"); // "default" — wrong!
console.log(name || "default"); // "default" — wrong!
// Typical API-response guard
function getPageSize(cfg) {
return cfg.pageSize ?? 20; // 0 is valid, won't fall back
}
// Nullish assignment ??= (ES2021)
let user = { name: "Alice", score: null };
user.score ??= 0; // assigns 0 — score was null
user.name ??= "Guest"; // no change — name already set
// Combined with optional chaining
const theme = settings?.ui?.theme ?? "#007bff";
Why it matters: Replacing || with ?? for defaults is one of the most impactful one-line bug fixes in production JS; console.log(0 || "x") is a classic litmus test for ES2020 knowledge.
Real applications: Vue 3 component props use ?? to distinguish unset props from props intentionally set to zero; Next.js API handlers use req.query.page ?? 1 for reliable pagination defaults.
Common mistakes: Mixing ?? with && or || without parentheses — JavaScript throws a SyntaxError because precedence is ambiguous; always use explicit grouping like (a ?? b) || c.
=>) provide a concise function expression syntax and, crucially, do not create their own this binding — they capture this lexically from the enclosing scope. They also lack an arguments object, cannot be used as constructors (no new), have no prototype property, and cannot be generator functions; returning an object literal requires wrapping it in parentheses.
// Syntax forms
const double = x => x * 2; // single param
const add = (a, b) => a + b; // multi-param
const getConfig = () => ({ debug: true }); // object literal
const log = (msg) => { console.log(msg); }; // block body
// Lexical this — arrow captures surrounding this
class Timer {
constructor() { this.ticks = 0; }
start() {
setInterval(() => this.ticks++, 1000); // this = Timer ✓
}
}
// Avoid arrow as object method — this is NOT the object
const obj = {
name: "Alice",
greetBad: () => "Hello, " + this?.name, // undefined (outer this)
greetGood: function() { return "Hello, " + this.name; } // "Alice"
};
Why it matters: Lexical this is one of the most frequently tested JavaScript topics; correctly identifying when an arrow function is or is not appropriate separates junior from senior candidates.
Real applications: React class components use arrow class fields (handleClick = () => {}) to bind event handlers without .bind(this); RxJS pipe operators use arrow callbacks to maintain observable chain context.
Common mistakes: Defining an object method as an arrow function — this refers to the enclosing lexical scope (likely undefined in strict mode), not the object itself.
undefined — not null, 0, or "". They are evaluated lazily at call time, so defaults can reference earlier parameters, call functions, or contain any valid expression.
// Basic defaults
function greet(name = "Guest", greeting = "Hello") {
return `${greeting}, ${name}!`;
}
greet(); // "Hello, Guest!"
greet("Alice"); // "Hello, Alice!"
greet("Bob", "Hi"); // "Hi, Bob!"
// Expression default referencing earlier param
function createElement(tag, cls = `${tag}--default`) {
return { tag, cls };
}
createElement("btn"); // { tag: "btn", cls: "btn--default" }
// Only undefined triggers default — null does NOT
function test(x = "fallback") { return x; }
test(undefined); // "fallback"
test(null); // null
test(0); // 0
test(""); // ""
// Destructured config with all defaults
function connect({ host = "localhost", port = 5432, ssl = false } = {}) {
return `${ssl ? "https" : "http"}://${host}:${port}`;
}
connect(); // "http://localhost:5432"
connect({ port: 3306 }); // "http://localhost:3306"
Why it matters: Default parameters are a daily-use ES6 feature; the null vs undefined distinction is a standard interview trap that reveals deep understanding of JavaScript's type system.
Real applications: Express.js middleware factories use default parameter destructuring so callers can omit the options argument entirely; React function components default props inline instead of using the deprecated defaultProps.
Common mistakes: Passing null expecting the default to activate — only undefined triggers defaults; in JavaScript null means "intentionally no value" and is passed through as-is.
...args) collect all remaining function arguments into a real Array instance. Unlike the legacy arguments object, rest parameters are a proper Array with full array methods, work inside arrow functions, and are explicitly named in the function signature. They must be the last parameter and replaced the need for Array.from(arguments) in modern code.
function sum(first, ...rest) {
return rest.reduce((total, n) => total + n, first);
}
sum(1, 2, 3, 4, 5); // 15
// Rest in destructuring
const [head, ...tail] = [1, 2, 3, 4];
// head = 1, tail = [2, 3, 4]
const { name, ...others } = { name: 'Alice', age: 30, role: 'dev' };
// name = 'Alice', others = { age: 30, role: 'dev' }
// arrow functions have no arguments object — rest is the solution
const log = (...msgs) => msgs.forEach(console.log);
Why it matters: Rest parameters are a daily-use ES6 feature for variadic functions. Understanding why arguments doesn't work in arrow functions — and that rest parameters do — is a core interview differentiator.
Real applications: Utility functions like compose(...fns), event emitters, logging helpers, and Redux middleware all leverage rest parameters to accept a variable number of arguments cleanly without the old Array.prototype.slice.call(arguments) boilerplate.
Common mistakes: Putting rest parameters anywhere other than the last position (syntax error), confusing rest (...args in params — collects into array) with spread (...arr in calls — expands from array), and accessing arguments in arrow functions expecting it to work.
constructor, instance methods on the prototype, static methods on the class, get/set accessors, extends for inheritance, and super() to call parent constructors and methods.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return this.name + " makes a sound";
}
static create(name) {
return new Animal(name);
}
get info() {
return "Animal: " + this.name;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // must call super before using this
this.breed = breed;
}
speak() {
return this.name + " barks";
}
}
const dog = new Dog("Rex", "Labrador");
console.log(dog.speak()); // "Rex barks"
console.log(dog.info); // "Animal: Rex"
Classes are not hoisted — you must define before using. Modern JS extends them with private fields (#field), private methods (#method()), and static initialization blocks (static {}).
Why it matters: Classes are the foundation of modern OOP in JavaScript — used extensively in React class components, Angular services and components, Node.js framework code, and TypeScript codebases. Understanding they're prototype sugar prevents confusion when debugging the prototype chain.
Real applications: Angular's entire DI system and component model is class-based. React class components use extends React.Component. Node.js frameworks like NestJS use classes with decorators for dependency injection and route handling.
Common mistakes: Forgetting super() before accessing this in a derived constructor (ReferenceError), treating class declarations as hoisted (they are in TDZ like let/const), and over-using inheritance where composition would be more flexible.
class BankAccount {
#balance = 0; // private field
#owner; // private field
constructor(owner, initialBalance) {
this.#owner = owner;
this.#balance = initialBalance;
}
#validate(amount) { // private method
return amount > 0 && amount <= this.#balance;
}
withdraw(amount) {
if (this.#validate(amount)) {
this.#balance -= amount;
return amount;
}
return 0;
}
get balance() {
return this.#balance;
}
}
const account = new BankAccount("Alice", 1000);
console.log(account.balance); // 1000
account.withdraw(200);
// account.#balance; // SyntaxError: Private field
// account.#validate; // SyntaxError: Private method
Private fields are truly inaccessible from outside the class — not through Object.keys(), JSON.stringify(), bracket notation, or reflection. Use static #field for private static properties shared across all instances.
Why it matters: Hard encapsulation prevents internal state corruption from external code. Unlike TypeScript's private keyword (which is only a compile-time check), JS private fields enforce privacy at runtime — accessing them from outside throws a SyntaxError.
Real applications: Class-based data structures (LinkedList, Queue), banking/payment classes, React class components with internal state, and any library API that must protect internal implementation details from consumers.
Common mistakes: Confusing TypeScript's private (compile-time only) with JS #field (true runtime privacy), forgetting to declare the field before the constructor (required at class level), and trying to access private fields from a subclass (they are NOT inherited — each class must define its own).