let, const, and Variable Scoping
let, const, and Variable Scoping
The Problem with var
var is function-scoped, which leads to surprising behavior in loops and conditionals. It is also hoisted to the top of its function scope, meaning a variable can be accessed before it is declared, resulting in undefined rather than a ReferenceError.
function varExample() {
if (true) {
var x = 10;
}
console.log(x); // 10 — var leaks out of the if block
}
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 3, 3, 3 — not 0, 1, 2
}
Block Scoping with let
let is block-scoped, meaning it only exists within the nearest set of curly braces. This makes it safe for loops, conditionals, and any block structure where you need a variable to be isolated.
function letExample() {
if (true) {
let y = 20;
}
// console.log(y); // ReferenceError: y is not defined
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100); // 0, 1, 2 — each iteration gets its own j
}
Const for Immutability References
const declares a variable that cannot be reassigned. It does not make the value itself immutable — objects and arrays declared with const can still have their contents modified. Use const by default and only switch to let when reassignment is genuinely needed.
const API_URL = 'https://api.example.com/v2';
// API_URL = 'https://other.com'; // TypeError: Assignment to constant variable
const user = { name: 'Alice' };
user.name = 'Bob'; // This works — the object's contents are mutable
// user = {}; // TypeError: Assignment to constant variable
const items = [1, 2, 3];
items.push(4); // Works — array contents are mutable
items = [5, 6]; // TypeError
Temporal Dead Zone (TDZ)
Both let and const are hoisted but are not initialized until their declaration is evaluated. Accessing them before declaration throws a ReferenceError. This period between hoisting and initialization is called the Temporal Dead Zone.
// console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 5;
// This is why using a variable before it is declared is called the TDZ
function tdzDemo() {
// The TDZ makes debugging easier than var's silent undefined
let message = 'hello';
}
When to Use Each
Use const for all declarations by default. Switch to let only when you need to reassign the variable, such as loop counters, accumulators, or variables that are updated conditionally. Never use var in modern JavaScript — it creates bugs that are hard to trace due to its function-scoping and hoisting behavior.
Arrow Functions and Default Parameters
Arrow Functions and Default Parameters
Arrow Function Syntax
Arrow functions provide a shorter syntax for writing functions. They have two main forms: expression-bodied functions for single return values, and block-bodied functions when you need multiple statements.
// Traditional function
function add(a, b) {
return a + b;
}
// Arrow function — expression body
const add = (a, b) => a + b;
// Single parameter — parentheses are optional
const double = n => n * 2;
// No parameters require empty parentheses
const greet = () => 'Hello!';
// Block body — must use explicit return
const processUser = (user) => {
const name = user.name.trim();
const email = user.email.toLowerCase();
return { name, email };
};
Lexical this Binding
Arrow functions do not have their own this context. They inherit this from the enclosing lexical scope. This makes them ideal for callbacks, event handlers, and methods where you want to preserve the outer this.
const team = {
name: 'Engineering',
members: ['Alice', 'Bob', 'Charlie'],
listMembers() {
// Arrow function inherits `this` from listMembers
this.members.forEach((member) => {
console.log(`${member} is on ${this.name}`);
});
},
createGreeting() {
// Arrow function captures `this` from createGreeting
return () => `Welcome to ${this.name}!`;
},
};
const greeting = team.createGreeting();
greeting(); // 'Welcome to Engineering!'
Default Parameters
Default parameters allow you to specify fallback values when arguments are not provided or are explicitly undefined. They are evaluated left-to-right and can reference earlier parameters.
function createUser(name, role = 'viewer', active = true) {
return { name, role, active };
}
createUser('Alice'); // { name: 'Alice', role: 'viewer', active: true }
createUser('Bob', 'admin'); // { name: 'Bob', role: 'admin', active: true }
createUser('Charlie', undefined, false); // uses default role
// Defaults can reference earlier params
function buildUrl(base, version = 1, path = `/api/v${version}`) {
return `${base}${path}`;
}
buildUrl('https://api.com'); // 'https://api.com/api/v1'
buildUrl('https://api.com', 2); // 'https://api.com/api/v2'
buildUrl('https://api.com', 2, '/users'); // 'https://api.com/users'
Higher-Order Functions with Arrows
Arrow functions pair naturally with array methods like map, filter, and reduce to create concise, readable data transformations.
const products = [
{ name: 'Laptop', price: 999, inStock: true },
{ name: 'Mouse', price: 29, inStock: true },
{ name: 'Monitor', price: 349, inStock: false },
{ name: 'Keyboard', price: 79, inStock: true },
];
const availableProducts = products
.filter(product => product.inStock)
.map(product => ({
...product,
displayPrice: `$${product.price}`,
}));
// [{ name: 'Laptop', price: 999, inStock: true, displayPrice: '$999' }, ...]
const totalValue = products
.filter(p => p.inStock)
.reduce((sum, p) => sum + p.price, 0); // 1107
Destructuring, Spread, and Rest
Destructuring, Spread, and Rest
Object Destructuring
Destructuring lets you extract values from objects and arrays into distinct variables. You can rename variables, provide defaults, and nest deeply.
const user = {
name: 'Alice',
age: 30,
address: { city: 'Seattle', state: 'WA' },
preferences: { theme: 'dark', language: 'en' },
};
// Basic destructuring
const { name, age } = user; // name = 'Alice', age = 30
// Rename variables
const { name: userName, age: userAge } = user;
// Provide defaults
const { role = 'viewer' } = user; // role = 'viewer' (not on user)
// Nested destructuring
const { address: { city, state } } = user; // city = 'Seattle'
// Deep nesting with rename
const { preferences: { theme: currentTheme } } = user; // currentTheme = 'dark'
Array Destructuring
Array destructuring uses position rather than names. You can skip elements and use rest syntax to capture remaining items.
const colors = ['red', 'green', 'blue', 'yellow', 'purple'];
// Skip elements
const [primary, , tertiary] = colors; // primary = 'red', tertiary = 'blue'
// Collect rest into array
const [first, second, ...remaining] = colors;
// first = 'red', second = 'green', remaining = ['blue', 'yellow', 'purple']
// Swap variables without temp
let a = 1, b = 2;
[a, b] = [b, a]; // a = 2, b = 1
// Function return values
function getUserCoords() {
return { lat: 47.6, lng: -122.3 };
}
const { lat, lng } = getUserCoords();
Spread Operator
The spread operator (...) expands arrays, objects, or iterables. It creates a shallow copy, which is useful for immutability patterns.
// Array spread
const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5]
// Merge arrays
const defaults = ['a', 'b', 'c'];
const custom = [...defaults, 'd', 'e']; // ['a', 'b', 'c', 'd', 'e']
// Object spread
const baseConfig = { host: 'localhost', port: 3000 };
const devConfig = { ...baseConfig, debug: true, port: 3001 };
// { host: 'localhost', port: 3001, debug: true } — later properties win
// Shallow clone for immutability
const original = { x: 1, y: { z: 2 } };
const clone = { ...original, x: 2 };
// clone.y === original.y (same reference — shallow copy)
Rest Parameters
Rest syntax collects remaining elements into an array. It works in function parameters and destructuring.
// Rest in function parameters
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3, 4); // 10
// Rest in destructuring
const { name, ...profileData } = user;
// name = 'Alice', profileData = { age: 30, address: {...}, preferences: {...} }
// Filter out a property
const { password, ...safeUser } = user;
// safeUser has everything except password
// Pass rest to another function
function logAll(first, ...rest) {
console.log('First:', first);
console.log('Rest:', rest);
}
logAll('a', 'b', 'c', 'd'); // First: a, Rest: ['b', 'c', 'd']
Template Literals and String Methods
Template Literals and String Methods
Template Literals Basics
Template literals use backticks instead of quotes. They support multi-line strings, embedded expressions, and tagged functions for custom parsing.
const name = 'World';
const greeting = `Hello, ${name}!`; // 'Hello, World!'
// Multi-line strings without concatenation
const html = `
<div class="card">
<h2>${user.name}</h2>
<p>${user.email}</p>
<span>Member since ${new Date().getFullYear()}</span>
</div>
`;
// Expression interpolation
const price = 49.99;
const quantity = 3;
const total = `Total: $${(price * quantity).toFixed(2)}`; // 'Total: $149.97'
// Nested ternaries and function calls
const status = `User ${user.name} is ${user.active ? 'active' : 'inactive'} since ${user.createdAt.toLocaleDateString()}`;
Tagged Template Literals
Tagged templates let you process a template literal through a custom function. The function receives the raw string parts and interpolated values separately, enabling safe HTML templating, internationalization, or syntax highlighting.
// SQL template tag for safe queries
function sql(strings, ...values) {
const query = strings.reduce((result, str, i) => {
const value = i < values.length ? values[i] : '';
return result + str + (value !== undefined ? value : '');
}, '');
return query;
}
const userId = 42;
const query = sql`SELECT * FROM users WHERE id = ${userId}`;
// 'SELECT * FROM users WHERE id = 42'
// HTML escaping tag
function safeHtml(strings, ...values) {
const escape = (str) => String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
return strings.reduce((result, str, i) => {
return result + str + (i < values.length ? escape(values[i]) : '');
}, '');
}
const userInput = '<script>alert("xss")</script>';
const safe = safeHtml`<p>User said: ${userInput}</p>`;
// '<p>User said: <script>alert("xss")</script></p>'
Modern String Methods
ES6+ introduced several string methods that make working with text cleaner and more predictable.
const str = ' Hello, World! ';
// startsWith, endsWith, includes
str.startsWith(' Hello'); // true
str.endsWith('! '); // true
str.includes('World'); // true
// repeat
'ha'.repeat(3); // 'hahaha'
// padStart, padEnd (great for formatting)
'42'.padStart(5, '0'); // '00042'
'hi'.padEnd(10, '.'); // 'hi........'
// trimStart, trimEnd
' hello '.trimStart(); // 'hello '
' hello '.trimEnd(); // ' hello'
// String.prototype.replaceAll (ES2021)
'hello-world-foo'.replaceAll('-', '_'); // 'hello_world_foo'
// at() method (ES2022)
'hello'.at(0); // 'h'
'hello'.at(-1); // 'o'
ES Modules and Code Organization
ES Modules and Code Organization
Named and Default Exports
ES modules let you split code into separate files. Each file is a module with its own scope. Named exports export multiple values, while default exports export a single main value per file.
// utils/math.js — named exports
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export default class Calculator {
result = 0;
add(n) { this.result += n; return this; }
subtract(n) { this.result -= n; return this; }
getValue() { return this.result; }
}
// app.js — imports
import Calculator from './utils/math.js'; // default
import { add, subtract } from './utils/math.js'; // named
import { add as addNumbers } from './utils/math.js'; // rename on import
import * as MathUtils from './utils/math.js'; // namespace all exports
const calc = new Calculator();
calc.add(5).add(10);
console.log(calc.getValue()); // 15
console.log(addNumbers(3, 4)); // 7
console.log(MathUtils.multiply(2, 5)); // 10
Dynamic Imports for Code Splitting
Dynamic imports load modules on demand, reducing initial bundle size. This is essential for route-based splitting and lazy loading heavy features.
// Button that loads a chart library on first click
const chartButton = document.getElementById('load-chart');
chartButton.addEventListener('click', async () => {
const { Chart, LineChart } = await import('./chart.js');
const chart = new LineChart(canvas, chartData);
chart.render();
});
// Route-based code splitting
const routes = {
'/dashboard': () => import('./pages/Dashboard.js'),
'/settings': () => import('./pages/Settings.js'),
'/analytics': () => import('./pages/Analytics.js'),
};
async function loadRoute(path) {
const loader = routes[path];
if (!loader) throw new Error(`Route ${path} not found`);
const module = await loader();
return module.default;
}
// Conditional module loading
if (process.env.NODE_ENV === 'development') {
const { DevTools } = await import('./dev/DevTools.js');
DevTools.init();
}
Module Patterns and Best Practices
Use barrel exports to re-export from a single entry point. Keep modules focused on one responsibility. Avoid circular dependencies by extracting shared interfaces into separate files.
// components/index.js — barrel export
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';
export { Card } from './Card.js';
// Usage — import from barrel
import { Button, Input, Modal } from './components';
// Re-export with renaming
export { default as TextInput } from './Input.js';
// Type re-exports (for TypeScript users)
export type { User, Product } from './types.js';
Top-Level Await
Modern JavaScript engines support await at the top level of a module. This simplifies initialization without wrapping everything in an async IIFE.
// config.js — top-level await
const response = await fetch('/config.json');
export const config = await response.json();
// database.js — initialize connection
export const db = await connectDatabase(config.databaseUrl);
// app.js — import ready-to-use values
import { config } from './config.js';
import { db } from './database.js';
// Both are already initialized when used
Quiz
1. What is the key difference between `let` and `var` in terms of scoping?
2. What does the following code output? ```javascript const fn = (x) => { return { value: x }; }; console.log(fn(5)); ```
3. Which of the following is true about ES modules?
Flashcards
Question
What is the Temporal Dead Zone (TDZ) and how does it affect `let` and `const`?
Click to reveal answer
Answer
The TDZ is the period between when a variable is hoisted and when its declaration is evaluated. Accessing a `let` or `const` variable in the TDZ throws a ReferenceError. Unlike `var`, which is initialized to `undefined` during hoisting, `let` and `const` remain uninitialized until their declaration line is reached.
Question
When should you use `const` over `let`, and why is `var` considered harmful?
Click to reveal answer
Answer
`const` should be the default choice for all declarations since it prevents accidental reassignment. Use `let` only when you genuinely need to reassign a variable (loop counters, accumulators). `var` is harmful because it is function-scoped instead of block-scoped, gets hoisted with an `undefined` initialization, and can be redeclared, all of which lead to hard-to-trace bugs.
Question
How does the spread operator differ from the rest parameter, and what do they have in common?
Click to reveal answer
Answer
Both use the `...` syntax. The spread operator expands an iterable (array, object, string) into individual elements — useful for copying, merging, and passing arguments. The rest parameter collects remaining elements into an array — useful in function parameters and destructuring. Spread goes from collection to elements, rest goes from elements to collection.
Revision Notes
Key Takeaways
- 1. Use `const` by default and `let` only when reassignment is needed; never use `var` in modern code
- 2. Arrow functions provide concise syntax and lexical `this` binding — ideal for callbacks and higher-order functions
- 3. Destructuring extracts values from objects and arrays into named variables; spread expands, rest collects
- 4. Template literals support multi-line strings, expression interpolation, and tagged functions for custom processing
- 5. ES modules use `import`/`export` syntax for clean dependency management; dynamic `import()` enables code splitting
Interview Tips
- • Explain the difference between `var`, `let`, and `const` with concrete code examples showing scoping behavior
- • Demonstrate how arrow functions differ from regular functions, especially regarding `this` binding and when you cannot use them (object methods, constructors, generators)
- • Show how to use destructuring to extract nested properties and rename variables in a single statement
- • Discuss when to use dynamic imports and how code splitting improves application performance
- • Explain the difference between spread and rest operators — spread expands collections, rest collects them
Cheat Sheet
Variables
const for constants, let for reassignable, never var
Arrow Functions
const fn = (params) => expression — lexical this, no own arguments
Destructuring
const { a, b: renamed } = obj; const [x, , z] = arr;
Spread
[...arr] expands arrays; { ...obj } expands objects; creates shallow copies
Rest
...args collects remaining params into an array; used in function signatures and destructuring
Template Literals
Hello, ${name} — interpolation, multi-line, tagged functions
Modules
export const/value; import { name } from './module.js'; import('./lazy.js') for dynamic
Optional Chaining
user?.address?.city — returns undefined instead of throwing on null/undefined
Nullish Coalescing
value ?? default — uses default only for null/undefined, not 0 or empty string