Skip to main content
JavaScript beginner Lesson 3 of 24

Variables in JavaScript: var, let, and const

Understand the differences between var, let, and const — including hoisting, temporal dead zone, block scope, and when to use each one.

Three Ways to Declare Variables

Variables are named containers that let your program store and reuse values. Without them you’d have to repeat every value everywhere, making code impossible to maintain. JavaScript has three keywords for declaring variables — var, let, and const — and they differ meaningfully in scope, hoisting behavior, and whether the binding can be reassigned. Choosing the right one makes your intent clear to anyone reading the code.

var   name = "Alice";   // function-scoped, hoisted
let   age  = 30;        // block-scoped, not hoisted
const PI   = 3.14159;   // block-scoped, not hoisted, no reassignment

Understanding the differences is one of the most important foundations in JavaScript.

var — Function Scope and Hoisting

var was the original variable declaration and is still valid JavaScript, but it comes with behavior that causes subtle bugs. The core issue is that var scopes to the enclosing function, not to the nearest block — so an if statement or a for loop does not create a new scope for var variables.

Function scope (not block scope)

function checkAge(age) {
  if (age >= 18) {
    var message = "Adult";
  }
  // message is accessible here — var ignores the if block
  console.log(message); // "Adult" or undefined
}

checkAge(20); // "Adult"
checkAge(15); // undefined  ← not an error, just undefined

With var, the if block creates no new scope. The variable leaks into the enclosing function.

Hoisting

JavaScript moves var declarations to the top of their function scope at compile time. Only the declaration is hoisted, not the assignment. This means you can reference a var variable before its line in the source without getting an error — you just get undefined, which is a silent bug that can be very hard to track down.

console.log(city); // undefined  (not a ReferenceError!)
var city = "Tokyo";
console.log(city); // "Tokyo"

// What the engine actually sees:
var city;           // declaration hoisted
console.log(city);  // undefined
city = "Tokyo";     // assignment stays in place
console.log(city);  // "Tokyo"

This is the classic var trap — you can use a variable before its line in the source code and get undefined instead of an error.

var in loops — a famous bug

Because var is function-scoped, every iteration of a loop shares the exact same variable. Closures inside the loop all capture the same reference, so by the time any callback runs, the loop has already finished and the variable holds its final value.

// Bug: every callback captures the same variable
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3  (not 0, 1, 2)

// Fix with let:
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2  ✓

This was one of the most common JavaScript bugs before let was introduced.

let — Block Scope

let was introduced in ES2015 and solves most of var’s problems. It scopes to the nearest enclosing block — any pair of curly braces {} — which is the intuitive behavior most programmers expect from other languages. Use let whenever you need a variable whose value will change after it is first assigned.

function getDiscount(isMember) {
  if (isMember) {
    let discount = 0.2;
    console.log(discount); // 0.2
  }
  // console.log(discount); // ReferenceError: discount is not defined
}

let respects block boundaries — anything inside {} creates a new scope.

Temporal Dead Zone (TDZ)

Unlike var, accessing a let variable before its declaration throws a ReferenceError. The period from the start of the block to the let declaration is called the temporal dead zone. This is intentional — it makes the bug visible rather than silently returning undefined, turning what would have been a confusing runtime quirk into a clear error message.

console.log(score); // ReferenceError: Cannot access 'score' before initialization
let score = 100;

The period from the start of the block to the let declaration is called the temporal dead zone. This is intentional — it makes the bug visible rather than silently returning undefined.

let is reassignable

let count = 0;
count = count + 1; // fine
count++;           // fine

const — Block Scope with Immutable Binding

const shares let’s block-scoping and TDZ behavior, but adds one constraint: the variable binding cannot be reassigned after initialization. This matters because most variables in well-written code are set once and never reassigned — using const signals that intent explicitly, making the code easier to follow and preventing accidental overwrites.

const MAX_RETRIES = 3;
MAX_RETRIES = 5; // TypeError: Assignment to constant variable.

const does not mean the value is immutable

This is the most common misconception about const. The keyword protects the binding — the connection between the name and the value — not the value itself. If the value is an object or array, its contents can still be changed freely.

const user = { name: "Alice", age: 30 };

// Mutating properties is allowed
user.age = 31;         // ✓
user.role = "admin";   // ✓

// Reassigning the binding is not
user = { name: "Bob" }; // TypeError ✗

// For a truly immutable object, use Object.freeze:
const config = Object.freeze({ apiUrl: "https://api.example.com", timeout: 5000 });
config.timeout = 9999; // silently fails (throws in strict mode)

The same applies to arrays:

const items = [1, 2, 3];
items.push(4);       // ✓  — mutates the array
items = [5, 6, 7];   // TypeError ✗ — reassigns the binding

Side-by-Side Comparison

Featurevarletconst
ScopeFunctionBlockBlock
HoistedYes (as undefined)TDZTDZ
ReassignableYesYesNo
Re-declarable in same scopeYesNoNo
Use in modern codeAvoidWhen neededDefault

When to Use Each

Use const by default. It signals to readers that this value doesn’t change, which reduces cognitive load. Most variables in well-written JavaScript are const.

const BASE_URL = "https://api.example.com";
const users = [];           // reference doesn't change, contents may
const fetchUser = async (id) => { /* ... */ };

Use let when you genuinely need to reassign. Counters, accumulators, and loop variables are the clearest cases.

let retries = 0;

while (retries < MAX_RETRIES) {
  try {
    await fetchData();
    break;
  } catch (err) {
    retries++;
  }
}

Avoid var in new code. There is no modern use case where var is preferable to let or const.

Common Pitfalls

Confusing const immutability with object immutability

const settings = { theme: "dark" };
settings.theme = "light"; // works — this surprises many beginners

Re-declaring with let in the same scope

let x = 1;
let x = 2; // SyntaxError: Identifier 'x' has already been declared

This is actually a feature — it catches copy-paste bugs that var would silently allow.

Forgetting const in destructuring

// Wrong — creates implicit globals in non-strict mode
{ name, age } = getUser();

// Right — const (or let) is required
const { name, age } = getUser();

What’s Next

Now that you understand how variables are stored, the next tutorial covers JavaScript’s data types — the different kinds of values you can put inside those variables.

Frequently Asked Questions

Should I ever use var in new code?
No. var has surprising hoisting behavior and function-level scoping that causes real bugs. Use const by default and let when you need to reassign. Reserve var for legacy codebases you're maintaining.
Can I change the properties of a const object?
Yes. const prevents reassigning the variable binding, not mutating the value. You can still push to a const array or update properties on a const object. Use Object.freeze() if you need true immutability.
What is the temporal dead zone?
The period between the start of a block scope and the point where a let or const variable is declared. Accessing the variable in this zone throws a ReferenceError — unlike var, which returns undefined.