Skip to main content

📦 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 let and constants with const, 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 const stops surprising you
  • Follow modern naming conventions and avoid the classic var pitfalls

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.

A const binding points at a mutable object The name user is a locked binding whose arrow points at an object in memory. The arrow cannot move, but the object's properties can still be changed. const user binding (locked 🔒) reference Object in memory name: "Grace" ✏️ role: "engineer" ✏️ properties can be mutated
Figure 1 — 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.

Nested scopes Global scope contains a function scope, which contains a block scope. Inner scopes can read outward but outer scopes cannot read inward. Global scope — const APP_NAME Function scope — const base Block scope — const discount Can read: discount ✅ base ✅ APP_NAME ✅ Outer scopes cannot read discount ❌
Figure 2 — Variable lookup travels outward through enclosing scopes (the "scope chain"), never 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';
flowchart LR A[Enter scope] --> B[Name is hoisted
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
Featurevarletconst
ScopeFunctionBlockBlock
ReassignableYesYesNo
Redeclarable in same scopeYesNoNo
Hoisted value before declarationundefinedTDZ (error)TDZ (error)
Recommended for new codeNoYesYes (default)
⚠️ Rule of thumb: In new code, never use var. Default to const, and use let only when you must reassign. If a linter flags a stray var, 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: age and Age are different variables.
  • You cannot use reserved words like let, class, or return as 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 for let only 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 var in new code.
  • Don't declare a variable far from where it's used just to "reserve" the name.
  • Don't assume const makes 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

  1. Explain in one sentence why every button logs 3.
  2. Change one keyword so each button logs its correct index.
  3. Also mark buttons with 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 const by default and let only when you must reassign.
  • const prevents reassignment, not mutation — objects and arrays behind a const can still change.
  • let and const are block-scoped; the old var is 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.