📦 Variables, Constants, and Scope
Every program is really just data moving through named boxes. This lesson shows you how JavaScript creates those boxes with let and const, why the old var keyword behaves so strangely, and how scope decides which parts of your code can see which values.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Declare variables with
letand constants withconst, and explain when to reach for each - Describe the difference between block scope and function scope
- Explain hoisting and the temporal dead zone in plain language
- Distinguish reassignment from mutation so
conststops surprising you - Follow modern naming conventions and avoid the classic
varpitfalls
Estimated Time: 25–35 minutes • Difficulty: Beginner
Hands-on: Refactor a buggy loop that leaks a var and fix it with block scope.
In This Lesson
What Is a Variable?
A variable is a named place to store a value so you can use it again later. Instead of repeating the literal 3.14159 everywhere, you store it once under a meaningful name and refer to that name. When the value changes, you change it in exactly one place.
💡 A useful analogy: Think of a variable as a labeled box. The label is the variable's name; whatever you put inside is its value. You can read what's in the box by naming it, and — depending on how you created it — you may or may not be allowed to swap the contents for something else.
Creating a variable happens in two steps that often appear on one line:
- Declaration — you announce the name (
let score;) - Assignment — you put a value in it (
score = 10;)
// Declaration and assignment on separate lines
let score; // declare — the box exists but is empty (undefined)
score = 10; // assign — now the box holds 10
// The common shorthand: declare and assign together
let playerName = 'Ada';
const MAX_LIVES = 3;
console.log(playerName, score, MAX_LIVES); // Ada 10 3
📖 Key Terms
Identifier: the name you give a variable (e.g. playerName).
Binding: the association between a name and a storage location in memory.
Initializer: the value assigned when the variable is first created (the part after =).
Declaring with let and const
Modern JavaScript gives you two keywords for creating variables, and the choice between them communicates intent to anyone reading your code.
let — a value that will change
Use let when you expect the value to be reassigned later: a counter, an accumulating total, a value read from user input.
let count = 0;
count = count + 1; // reassigning is fine
count += 1; // same thing, shorter
console.log(count); // 2
const — a binding that never changes
Use const when the variable should always point at the same value. Trying to reassign a const throws an error, which is a feature: it stops an entire class of bugs before they happen.
const TAX_RATE = 0.0825;
// TAX_RATE = 0.1; // ❌ TypeError: Assignment to constant variable.
const greeting = 'Hello';
console.log(`${greeting}, world!`); // Hello, world!
A const must be initialized on the same line it is declared — you cannot declare it empty and fill it in later.
// const total; // ❌ SyntaxError: Missing initializer in const declaration
const total = 0; // ✅ initialized immediately
✅ The default choice
Reach for const first. Only switch to let when you discover you actually need to reassign the variable. This habit makes your code easier to reason about — a const is a promise that the name will keep pointing at the same thing.
const: Reassignment vs. Mutation
Here is the single biggest source of confusion about const: it prevents reassignment, not mutation. The binding is locked, but if the value is an object or array, its contents can still be changed.
const user = { name: 'Ada' };
user.name = 'Grace'; // ✅ allowed — we mutate the object, not the binding
user.role = 'engineer'; // ✅ allowed — adding a property is also mutation
console.log(user); // { name: 'Grace', role: 'engineer' }
// user = { name: 'Alan' }; // ❌ TypeError — this reassigns the binding
const scores = [10, 20];
scores.push(30); // ✅ allowed — mutating the array
console.log(scores); // [10, 20, 30]
Why is this allowed? Because a const that holds an object stores a reference — an arrow pointing at the object in memory. const freezes the arrow, not the thing it points to.
const locks the binding (the arrow) but not the object it references. To truly prevent changes to an object's own properties, use Object.freeze().If you genuinely need an object that cannot be changed, freeze it:
const config = Object.freeze({ theme: 'dark', debug: false });
config.theme = 'light'; // silently ignored (throws in strict mode)
console.log(config.theme); // 'dark'
Scope: Who Can See What
Scope is the region of your program where a variable is visible. Both let and const are block-scoped: they exist only inside the nearest pair of curly braces { } — an if block, a loop body, or a function body.
function checkout(isMember) {
const base = 100; // visible in the whole function
if (isMember) {
const discount = 10; // visible ONLY inside this if-block
console.log(base - discount); // 90
}
// console.log(discount); // ❌ ReferenceError: discount is not defined
return base;
}
Scopes nest like Russian dolls. An inner scope can read variables from the scopes that surround it, but an outer scope cannot reach inward.
💡 Why block scope matters
Block scope keeps variables small and local, so a name you use inside a loop can't accidentally clobber a value somewhere else in the function. Smaller scopes mean fewer places a bug can hide.
Hoisting & the Temporal Dead Zone
Hoisting is JavaScript's behavior of processing declarations before it runs your code line by line. The name is "known" from the top of its scope — but with let and const, you still cannot use it before the line that declares it.
That gap between entering the scope and reaching the declaration is called the Temporal Dead Zone (TDZ). Touch the variable inside the TDZ and you get an error, which is far friendlier than silently getting undefined.
console.log(town); // ❌ ReferenceError: Cannot access 'town' before initialization
let town = 'Cebu';
// Compare with var, which is hoisted AND initialized to undefined:
console.log(city); // undefined — no error, but almost never what you want
var city = 'Manila';
but uninitialized] B --> C{Reach the
let/const line?} C -->|Not yet| D[Temporal Dead Zone
using it throws] C -->|Yes| E[Initialized
safe to use] D --> C
The practical takeaway: declare variables before you use them. The TDZ exists to turn "used too early" from a silent bug into a loud, easy-to-fix error.
The Legacy var Keyword
Before 2015 (ES6), var was the only way to declare a variable. You will still meet it in older code, so you need to recognize its quirks — but you should not write new code with it.
Quirk 1: var is function-scoped, not block-scoped
function demo() {
if (true) {
var x = 1; // NOT confined to the if-block
let y = 2; // confined to the if-block
}
console.log(x); // 1 — var leaked out to the whole function
// console.log(y); // ❌ ReferenceError
}
Quirk 2: the classic loop bug
Because var ignores block scope, a single var is shared by every iteration of a loop — a notorious trap with asynchronous callbacks:
// With var: all three timers print 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var:', i), 10);
}
// Output: var: 3, var: 3, var: 3
// With let: each iteration gets its own i
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let:', j), 10);
}
// Output: let: 0, let: 1, let: 2
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Reassignable | Yes | Yes | No |
| Redeclarable in same scope | Yes | No | No |
| Hoisted value before declaration | undefined | TDZ (error) | TDZ (error) |
| Recommended for new code | No | Yes | Yes (default) |
⚠️ Rule of thumb: In new code, never usevar. Default toconst, and useletonly when you must reassign. If a linter flags a strayvar, treat it as a signal to modernize.
Naming & Best Practices
Good names are the cheapest documentation you will ever write. JavaScript has a few hard rules and several strong conventions.
The rules (enforced by the language)
- Names may contain letters, digits,
$, and_, but cannot start with a digit. - Names are case-sensitive:
ageandAgeare different variables. - You cannot use reserved words like
let,class, orreturnas names.
The conventions (enforced by good taste)
const firstName = 'Ada'; // camelCase for variables and functions
const MAX_RETRIES = 5; // UPPER_SNAKE_CASE for fixed "magic" constants
let isLoading = false; // boolean names read like yes/no questions
const userList = []; // plural names for collections
// Avoid names that don't say anything:
let x = getUser(); // 👎 what is x?
const currentUser = getUser(); // 👍 self-explanatory
✅ Do
- Default to
const; reach forletonly when reassigning. - Declare each variable as close as possible to where it is first used.
- Choose descriptive names — future-you will thank present-you.
⚠️ Don't
- Don't use
varin new code. - Don't declare a variable far from where it's used just to "reserve" the name.
- Don't assume
constmakes an object immutable — it only locks the binding.
Hands-on Exercise
🏋️ Fix the Leaky Loop
Objective: Use block scope and the right declaration keyword to fix a real bug.
The function below is meant to build three buttons, each of which alerts its own index when clicked. Instead, every button reports 3. Find out why, then fix it.
function makeButtons() {
const buttons = [];
for (var i = 0; i < 3; i++) {
buttons.push({
label: 'Button ' + i,
onClick: function () { console.log('Clicked button', i); }
});
}
return buttons;
}
const btns = makeButtons();
btns[0].onClick(); // logs "Clicked button 3" 😱 (expected 0)
btns[1].onClick(); // logs "Clicked button 3" 😱 (expected 1)
Your task
- Explain in one sentence why every button logs
3. - Change one keyword so each button logs its correct index.
- Also mark
buttonswith the declaration that best expresses that the binding never changes.
💡 Hint
The loop uses a single function-scoped var i that all three closures share. By the time any onClick runs, the loop has finished and i is 3. You need each iteration to capture its own copy of the counter.
✅ Solution
Swap var for let. With let, the loop creates a fresh block-scoped i for every iteration, so each closure captures a different value. buttons is never reassigned (only mutated with push), so const is correct for it.
function makeButtons() {
const buttons = []; // never reassigned → const
for (let i = 0; i < 3; i++) { // let → one fresh i per iteration
buttons.push({
label: 'Button ' + i,
onClick: function () { console.log('Clicked button', i); }
});
}
return buttons;
}
const btns = makeButtons();
btns[0].onClick(); // "Clicked button 0" ✅
btns[1].onClick(); // "Clicked button 1" ✅
btns[2].onClick(); // "Clicked button 2" ✅
🎯 Quick Quiz
Question 1: Which declaration should you reach for first when writing new code?
Question 2: Given const nums = [1, 2];, which line runs without throwing an error?
Question 3: What happens when you read a let variable before its declaration line?
Summary & Quiz
🎉 Key Takeaways
- A variable is a named binding to a value; declare it, then assign to it.
- Use
constby default andletonly when you must reassign. constprevents reassignment, not mutation — objects and arrays behind aconstcan still change.letandconstare block-scoped; the oldvaris function-scoped and error-prone.- The temporal dead zone turns "used before declared" into a clear error instead of a silent bug.
📚 Further Reading
🚀 What's Next?
Now that you can store values, the next question is what kinds of values there are. Up next we'll survey JavaScript's primitive data types — strings, numbers, booleans, and the rest — and how each one behaves.
🎉 Nice work!
You now know how JavaScript names and scopes its data. That foundation underpins everything else in the language.