Why Understanding Variable Declarations Matters
JavaScript provides three ways to declare variables: var, const, and let. Each has distinct behaviors that affect:
- Scope - Where variables are accessible
- Hoisting - How variables are processed before execution
- Reassignment - Whether values can change
- Initialization - When variables can be used
Understanding these differences helps you:
- Write more predictable code
- Avoid common bugs
- Follow modern JavaScript best practices
- Debug scope-related issues
- Perform better in technical interviews
This guide explains each declaration type with practical examples and real-world scenarios.
Quick Comparison Table
Feature | var | let | const
-----------------|------------------|------------------|------------------
Scope | Function/Local | Block | Block
Hoisting | Yes (undefined) | Yes (TDZ) | Yes (TDZ)
Reassignment | Yes | Yes | No
Re-declaration | Yes | No | No
Initialization | Optional | Optional | Required
Temporal Dead Zone| No | Yes | Yes
var: The Legacy Declaration
What Is var?
var is the original way to declare variables in JavaScript. It was introduced in the first version of JavaScript and has function-scoped behavior.
Key Characteristics
- Function-scoped (or globally-scoped if declared outside a function)
- Hoisted to the top of its scope
- Can be re-declared in the same scope
- Can be reassigned
- No Temporal Dead Zone (can be accessed before declaration)
Scope Behavior
function example() {
if (true) {
var x = 10;
}
console.log(x); // 10 - accessible outside the block
}
example();
console.log(x); // ReferenceError: x is not defined
Important: var is function-scoped, not block-scoped. Variables declared with var inside blocks (if, for, while) are accessible throughout the entire function.
Hoisting with var
console.log(x); // undefined (not ReferenceError!)
var x = 5;
console.log(x); // 5
What happens:
- JavaScript hoists the declaration (
var x;) to the top - Variable is initialized with
undefined - Assignment happens at the original line
- This is equivalent to:
var x; // hoisted declaration
console.log(x); // undefined
x = 5; // assignment
console.log(x); // 5
Re-declaration
var x = 10;
var x = 20; // No error - re-declaration allowed
console.log(x); // 20
Common Pitfalls with var
1. Loop Variable Leakage
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // Prints 3, 3, 3 (not 0, 1, 2!)
}, 100);
}
Why? The var i is function-scoped, so all callbacks reference the same variable after the loop completes.
Solution: Use let (see below) or create a closure:
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => {
console.log(j); // Prints 0, 1, 2
}, 100);
})(i);
}
2. Accidental Global Variables
function example() {
x = 10; // Missing var/let/const - creates global variable!
}
example();
console.log(x); // 10 - accessible globally (bad!)
Always declare variables explicitly.
3. Variable Shadowing Issues
var x = 10;
function test() {
var x = 20; // Shadows outer x
if (true) {
var x = 30; // Re-declares same variable
console.log(x); // 30
}
console.log(x); // 30 (not 20!)
}
test();
console.log(x); // 10
let: Block-Scoped Variables
What Is let?
let was introduced in ES6 (ES2015) to address var's limitations. It provides block-scoped variables with better predictability.
Key Characteristics
- Block-scoped - Limited to the nearest enclosing block
- Hoisted but in Temporal Dead Zone (TDZ)
- Cannot be re-declared in the same scope
- Can be reassigned
- Temporal Dead Zone - Cannot be accessed before declaration
Scope Behavior
function example() {
if (true) {
let x = 10;
console.log(x); // 10
}
console.log(x); // ReferenceError: x is not defined
}
example();
Important: let is block-scoped. Variables are only accessible within the block where they're declared.
Hoisting and Temporal Dead Zone
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
What happens:
- JavaScript hoists the declaration (
let x;) to the top - Variable enters Temporal Dead Zone (TDZ)
- Accessing variable in TDZ throws ReferenceError
- Variable is initialized when execution reaches the declaration line
TDZ exists from the start of the scope until the declaration line:
{
// TDZ starts here
console.log(x); // ReferenceError - still in TDZ
let x = 5; // TDZ ends here
console.log(x); // 5
}
No Re-declaration
let x = 10;
let x = 20; // SyntaxError: Identifier 'x' has already been declared
Exception: Different scopes can have variables with the same name:
let x = 10;
if (true) {
let x = 20; // Different scope - OK
console.log(x); // 20
}
console.log(x); // 10
Loop Variable Behavior
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // Prints 0, 1, 2 (correct!)
}, 100);
}
Why? Each iteration creates a new binding for i, so each callback captures its own copy.
When to Use let
- When you need to reassign the variable
- In loops (for, while, for...of, for...in)
- When you need block scope
- For variables that change over time
let count = 0;
for (let i = 0; i < 10; i++) {
count += i;
}
console.log(count); // 45
const: Immutable Bindings
What Is const?
const was introduced in ES6 alongside let. It creates block-scoped variables that cannot be reassigned.
Key Characteristics
- Block-scoped - Same as
let - Hoisted but in Temporal Dead Zone (TDZ)
- Cannot be re-declared in the same scope
- Cannot be reassigned - Binding is immutable
- Must be initialized when declared
- Temporal Dead Zone - Cannot be accessed before declaration
Scope Behavior
function example() {
if (true) {
const x = 10;
console.log(x); // 10
}
console.log(x); // ReferenceError: x is not defined
}
example();
Must Be Initialized
const x; // SyntaxError: Missing initializer in const declaration
const y = 10; // OK
Immutability: Binding vs Value
Important distinction: const prevents reassignment of the binding, not mutation of the value.
Primitive Values (Truly Immutable)
const x = 10;
x = 20; // TypeError: Assignment to constant variable
Objects and Arrays (Binding Immutable, Value Mutable)
const obj = { name: "John" };
obj.name = "Jane"; // OK - mutating property
obj.age = 30; // OK - adding property
obj = {}; // TypeError: Assignment to constant variable
const arr = [1, 2, 3];
arr.push(4); // OK - mutating array
arr[0] = 10; // OK - mutating element
arr = []; // TypeError: Assignment to constant variable
To make objects/arrays immutable, use:
Object.freeze()- Shallow freezeObject.seal()- Prevents adding/removing properties- Libraries like Immutable.js
- Deep cloning before modification
const obj = Object.freeze({ name: "John" });
obj.name = "Jane"; // Silent failure in strict mode, TypeError otherwise
When to Use const
- For values that shouldn't be reassigned
- For function declarations (use
constwith arrow functions) - For imported modules
- For configuration values
- Default choice - Use
constunless you need reassignment
const PI = 3.14159;
const API_URL = "https://api.example.com";
const config = { timeout: 5000 };
// Function expressions
const greet = (name) => `Hello, ${name}!`;
// Array/object references
const users = [];
const settings = {};
Hoisting: How JavaScript Processes Variables
What Is Hoisting?
Hoisting is JavaScript's behavior of moving variable and function declarations to the top of their containing scope during compilation before code execution.
Important: Only declarations are hoisted, not initializations.
How Hoisting Works
console.log(x); // undefined
var x = 5;
JavaScript processes this as:
var x; // Declaration hoisted
console.log(x); // undefined
x = 5; // Assignment stays in place
Hoisting with var
function example() {
console.log(x); // undefined
var x = 10;
console.log(x); // 10
}
Execution order:
- Declaration (
var x;) hoisted to top - Variable initialized with
undefined - Code executes line by line
- Assignment happens at original location
Hoisting with let and const
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 10;
What happens:
- Declaration (
let x;) hoisted to top - Variable enters Temporal Dead Zone
- Accessing variable throws ReferenceError
- Variable initialized when execution reaches declaration
Function Hoisting
Functions are also hoisted, but behavior differs by declaration type:
Function Declarations (Fully Hoisted)
sayHello(); // "Hello!" - Works!
function sayHello() {
console.log("Hello!");
}
Function declarations are hoisted entirely - both declaration and definition.
Function Expressions (Not Hoisted)
sayHello(); // TypeError: sayHello is not a function
var sayHello = function () {
console.log("Hello!");
};
What happens:
var sayHellohoisted (undefined)- Calling
sayHello()throws TypeError - Function assigned later
Arrow Functions (Not Hoisted)
sayHello(); // ReferenceError: Cannot access 'sayHello' before initialization
const sayHello = () => {
console.log("Hello!");
};
Arrow functions follow the hoisting rules of their declaration type (const, let, or var).
Hoisting Order
JavaScript hoists in this order:
- Function declarations
- Variable declarations (
var) letandconstdeclarations (but remain in TDZ)
console.log(typeof x); // "function" (function declaration wins)
var x = 10;
function x() {
return "function";
}
console.log(typeof x); // "number"
Practical Hoisting Examples
Example 1: Variable Shadowing
var x = 10;
function test() {
console.log(x); // undefined (not 10!)
var x = 20;
console.log(x); // 20
}
test();
Why undefined? The local var x is hoisted, shadowing the outer x.
Example 2: Loop with var
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function () {
return i;
});
}
console.log(funcs[0]()); // 3 (all functions return 3)
console.log(funcs[1]()); // 3
console.log(funcs[2]()); // 3
Why? All functions reference the same hoisted var i after the loop completes.
Solution with let:
var funcs = [];
for (let i = 0; i < 3; i++) {
funcs.push(function () {
return i;
});
}
console.log(funcs[0]()); // 0
console.log(funcs[1]()); // 1
console.log(funcs[2]()); // 2
Scope: Where Variables Live
Scope Types
1. Global Scope
Variables declared outside any function or block:
var globalVar = "I'm global";
let globalLet = "I'm also global";
const globalConst = "Me too";
function test() {
console.log(globalVar); // Accessible
console.log(globalLet); // Accessible
console.log(globalConst); // Accessible
}
2. Function Scope
Variables accessible within the entire function (var):
function example() {
if (true) {
var x = 10;
}
console.log(x); // 10 - accessible throughout function
}
3. Block Scope
Variables accessible only within the block (let, const):
function example() {
if (true) {
let x = 10;
const y = 20;
}
console.log(x); // ReferenceError
console.log(y); // ReferenceError
}
Scope Chain
JavaScript uses lexical scoping - inner scopes can access outer scopes:
const global = "global";
function outer() {
const outerVar = "outer";
function inner() {
const innerVar = "inner";
console.log(global); // "global" - from global scope
console.log(outerVar); // "outer" - from outer scope
console.log(innerVar); // "inner" - from inner scope
}
inner();
}
outer();
Shadowing
Inner scopes can declare variables with the same name as outer scopes:
let x = "outer";
function test() {
let x = "inner";
console.log(x); // "inner"
}
test();
console.log(x); // "outer"
Best practice: Avoid shadowing to prevent confusion.
Temporal Dead Zone (TDZ)
What Is TDZ?
The Temporal Dead Zone is the period between entering a scope and the variable declaration where accessing the variable throws a ReferenceError.
TDZ with let
{
// TDZ starts
console.log(x); // ReferenceError
let x = 10; // TDZ ends
console.log(x); // 10
}
TDZ with const
{
// TDZ starts
console.log(x); // ReferenceError
const x = 10; // TDZ ends
console.log(x); // 10
}
Why TDZ Exists
TDZ prevents accessing variables before initialization, catching bugs early:
// Without TDZ (var behavior - buggy)
console.log(x); // undefined (silent bug)
var x = 10;
// With TDZ (let/const - catches error)
console.log(x); // ReferenceError (catches bug immediately)
let x = 10;
TDZ Examples
Example 1: TDZ in Same Scope
function test() {
console.log(x); // ReferenceError
let x = 10;
}
Example 2: TDZ Across Blocks
{
console.log(x); // ReferenceError - x is in TDZ
let x = 10;
}
Example 3: TDZ with typeof
console.log(typeof x); // "undefined" (safe for var)
console.log(typeof y); // ReferenceError (TDZ for let)
let y = 10;
Note: typeof is safe with var but throws in TDZ for let/const.
Best Practices
1. Prefer const by Default
// ✅ Good: Use const unless reassignment needed
const users = [];
const config = { apiUrl: "https://api.example.com" };
// Only use let when you need reassignment
let count = 0;
count++; // Reassignment needed
2. Use let for Loop Variables
// ✅ Good
for (let i = 0; i < items.length; i++) {
// ...
}
// ❌ Avoid
for (var i = 0; i < items.length; i++) {
// ...
}
3. Avoid var in Modern JavaScript
// ❌ Avoid var
var x = 10;
// ✅ Use const or let
const x = 10; // If no reassignment
let y = 10; // If reassignment needed
4. Declare Variables at the Top of Scope
// ✅ Good: Clear and predictable
function example() {
const x = 10;
const y = 20;
if (condition) {
// ...
}
}
// ❌ Avoid: Scattered declarations
function example() {
if (condition) {
const x = 10;
}
const y = 20;
}
5. Initialize Variables When Declaring
// ✅ Good
const name = "John";
let count = 0;
// ❌ Avoid (when possible)
let name;
// ... later
name = "John";
6. Use Descriptive Names
// ✅ Good
const userCount = 10;
const apiBaseUrl = "https://api.example.com";
// ❌ Avoid
const uc = 10;
const url = "https://api.example.com";
7. Group Related Declarations
// ✅ Good: Grouped by purpose
const API_BASE_URL = "https://api.example.com";
const API_TIMEOUT = 5000;
const API_RETRIES = 3;
let userCount = 0;
let activeUsers = [];
Common Mistakes and How to Avoid Them
Mistake 1: Using var in Loops
// ❌ Problem
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // Prints 3, 3, 3
}
// ✅ Solution
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // Prints 0, 1, 2
}
Mistake 2: Reassigning const
// ❌ Problem
const arr = [1, 2, 3];
arr = [4, 5, 6]; // TypeError
// ✅ Solution
let arr = [1, 2, 3];
arr = [4, 5, 6]; // OK
Mistake 3: Accessing Before Declaration
// ❌ Problem
console.log(x); // ReferenceError
let x = 10;
// ✅ Solution
let x = 10;
console.log(x); // 10
Mistake 4: Assuming const Makes Objects Immutable
// ❌ Problem: Thinking const prevents mutation
const obj = { name: "John" };
obj.name = "Jane"; // This works! const only prevents reassignment
// ✅ Solution: Use Object.freeze() if needed
const obj = Object.freeze({ name: "John" });
obj.name = "Jane"; // Fails in strict mode
Mistake 5: Re-declaring in Same Scope
// ❌ Problem
let x = 10;
let x = 20; // SyntaxError
// ✅ Solution
let x = 10;
x = 20; // Reassignment, not re-declaration
Real-World Examples
Example 1: Configuration Object
// ✅ Good: Use const for configuration
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
// Mutating properties is OK
config.timeout = 10000;
// Reassignment is not
// config = {}; // TypeError
Example 2: Event Handlers
// ✅ Good: Use const for function references
const handleClick = (event) => {
console.log("Clicked:", event.target);
};
button.addEventListener("click", handleClick);
Example 3: Loop with Closures
// ✅ Good: let creates new binding each iteration
const buttons = document.querySelectorAll("button");
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", () => {
console.log(`Button ${i} clicked`); // Correct index
});
}
Example 4: Module Exports
// ✅ Good: Use const for exports
const API_BASE_URL = "https://api.example.com";
const MAX_RETRIES = 3;
export { API_BASE_URL, MAX_RETRIES };
Example 5: State Management
// ✅ Good: Use let for changing state
let currentUser = null;
function login(user) {
currentUser = user; // Reassignment needed
}
function logout() {
currentUser = null; // Reassignment needed
}
Interview Questions and Answers
Q1: What's the difference between var, let, and const?
Answer:
var: Function-scoped, hoisted withundefined, can be re-declaredlet: Block-scoped, hoisted but in TDZ, cannot be re-declared, can be reassignedconst: Block-scoped, hoisted but in TDZ, cannot be re-declared, cannot be reassigned, must be initialized
Q2: What is hoisting?
Answer:
Hoisting is JavaScript's behavior of moving declarations to the top of their scope. Only declarations are hoisted, not initializations. var is hoisted with undefined, while let and const are hoisted but remain in Temporal Dead Zone.
Q3: What is the Temporal Dead Zone?
Answer:
TDZ is the period between entering a scope and the variable declaration where accessing the variable throws a ReferenceError. It exists for let and const but not for var.
Q4: Can you reassign a const variable?
Answer:
No, you cannot reassign the binding. However, if const holds an object or array, you can mutate its properties/elements. The binding is immutable, not the value.
Q5: Why does this code print 3, 3, 3?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
Answer:
Because var is function-scoped, all callbacks reference the same i variable after the loop completes (value 3). Use let to create a new binding each iteration.
Key Takeaways
- Use
constby default - Only useletwhen you need reassignment - Avoid
var- Useletorconstin modern JavaScript - Understand scope -
varis function-scoped,let/constare block-scoped - Know hoisting - All declarations are hoisted, but behavior differs
- Respect TDZ -
let/constcannot be accessed before declaration - const prevents reassignment - Not mutation of objects/arrays
- Block scope prevents leaks - Use
let/constin loops and blocks - Declare at top - Makes code more predictable and easier to read
Summary
Understanding var, let, const, and hoisting is fundamental to writing effective JavaScript:
var: Legacy, function-scoped, avoid in modern codelet: Block-scoped, reassignable, use when values changeconst: Block-scoped, immutable binding, use by default- Hoisting: All declarations move to top, but initialization differs
- TDZ: Prevents accessing
let/constbefore declaration
Modern JavaScript best practice: Use const by default, let when reassignment is needed, and avoid var entirely.
Master these concepts, and you'll write more predictable, maintainable JavaScript code.