Scope — var vs let vs const

8 min read

When you declare a variable, you also declare where it can be seen. That visibility — the region of code where the name is live — is called scope, and JavaScript's three declaration keywords (var, let, const) come with three different scoping rules. Picking the wrong one is the source of a whole genre of subtle test bugs. This lesson explains the three scopes you'll meet, why modern code uses let and const, and the small const gotcha that catches everyone once.

What scope means

A variable is in scope wherever you can read or write it without an error. Outside its scope, the name effectively doesn't exist — a fresh console.log(x) throws ReferenceError: x is not defined.

JavaScript has three flavours of scope:

  • Global scope — declared at the top of a file (or with no declaration at all in old code). Accessible everywhere.
  • Function scope — declared inside a function. Accessible only within that function.
  • Block scope — declared inside a {} block (an if, for, or any pair of braces). Accessible only within that block.

Global scope — usually a smell

const baseUrl = "https://staging.myapp.com";  // global
 
function logTarget() {
  console.log(`Running against ${baseUrl}`);  // can see baseUrl
}
 
logTarget();

Output:

Running against https://staging.myapp.com

Globals are convenient — every function can read them — and that's also what makes them dangerous. Two test files that both define const user = ... at the top are using a shared name; one accidental import or a build that bundles them together produces a clash. In real test suites, prefer functions, parameters, and exports over global variables.

Function scope

Variables declared inside a function only exist inside it.

function login(user) {
  const token = `tok-${Date.now()}`;
  console.log(`${user} got ${token}`);
}
 
login("alice");
console.log(token);  // ❌ ReferenceError: token is not defined

token is born when login runs and dies the moment the function returns. The outer code can't see it — which is the point. The function's internals stay private; callers don't accidentally depend on them.

Block scope (let and const)

let and const go further — they're scoped to the nearest pair of {}, even an if or for block.

if (true) {
  const message = "inside the if";
  console.log(message);   // works — inside the block
}
 
console.log(message);     // ❌ ReferenceError: message is not defined

Loop counters, if-block locals, helper variables in a try — all stay neatly inside the brace pair. The outside world never sees them.

var — function-scoped, surprising, legacy

var is the original keyword from 1995, and it ignores block scope entirely. A var declared inside an if leaks out of the block:

if (true) {
  var leaked = "I escape!";
}
 
console.log(leaked);  // "I escape!" — works, but shouldn't

In old code this was just how things worked; modern teams treat it as a bug magnet. The same name leaking out of a loop or if-block clashes with outer code, breaks refactors, and makes test isolation harder. The fix is the same in every case: use let or const instead.

The temporal dead zone

let and const add another safety net. Reading them before their declaration line throws an error:

console.log(x);   // ❌ ReferenceError: Cannot access 'x' before initialization
let x = 5;

var, by contrast, silently gives you undefined:

console.log(y);   // undefined  — no error, just a wrong value
var y = 5;

That zone — from the start of the block up to the actual let/const line — is called the temporal dead zone. Hitting it is annoying when you first meet it, but it's saving you from a bug that var would have shipped silently.

const isn't immutable — it's not-reassignable

A common misreading. const prevents reassignment of the binding, not mutation of the value the binding points at. Primitive values (numbers, strings, booleans) can't be mutated anyway, so const looks immutable for them. Objects and arrays can be mutated under a const:

const user = { name: "Alice", role: "admin" };
user.name = "Alicia";       // ✅ legal — mutating the object
console.log(user);          // { name: "Alicia", role: "admin" }
 
user = { name: "Bob" };     // ❌ TypeError: Assignment to constant variable

In test code: const config = { ... } is fine, but be careful — a helper that mutates config.timeout will affect every later test, even though the variable was declared const. If you want true immutability, deep-copy the object or use Object.freeze.

A real bug — the loop counter that leaks

Pretend an old codebase uses var for a loop counter, and a test attaches an async callback inside the loop:

const buttons = ["a", "b", "c"];
for (var i = 0; i < buttons.length; i++) {
  setTimeout(() => console.log("clicked button", buttons[i]), 0);
}

Output:

clicked button undefined
clicked button undefined
clicked button undefined

var i is one variable shared across all iterations. By the time the timeouts fire, i is 3, so buttons[3] is undefined for every callback. Switch to let and the bug evaporates:

for (let i = 0; i < buttons.length; i++) {
  setTimeout(() => console.log("clicked button", buttons[i]), 0);
}
// clicked button a
// clicked button b
// clicked button c

let creates a fresh i each iteration, so each callback closes over its own value. This bug — exact shape, exact symptoms — has surfaced in every QA codebase that mixes legacy var loops with async behaviour.

The three scopes at a glance

JavaScript scope
  • – Top of file
  • – Visible everywhere
  • – Avoid in tests
  • – Inside function {}
  • – let / const / var all work
  • – Private to the function
  • Inside if / for / { } –
  • let and const only –
  • var leaks out — avoid –

The practical rule

For any JavaScript you write today:

  1. Use const by default.
  2. Switch to let only when you genuinely need to reassign — counters, accumulators, swapping values.
  3. Never use var. If a linter still allows it, configure ESLint's no-var rule.

That single rule prevents the entire class of scope leaks above and forces you to think about which values are constants (most of them) and which actually mutate.

⚠️ Common mistakes

  • Assuming const makes objects immutable. It only stops reassignment of the name. Mutating the object's contents is still allowed and very easy to do by accident.
  • Mixing var and let in the same scope. Hoisting interactions get weird quickly. Pick let/const and stay there.
  • Declaring loop counters with var. The async-callback bug above is the classic case, but the same problem appears any time something inside the loop body lives longer than the iteration. let (or for...of) is the fix.

🎯 Practice task

See scope misbehave, then fix it. 15-20 minutes.

  1. In your js-for-qa folder, create scope.js.
  2. Paste the buggy var loop with setTimeout from this lesson. Run it with node scope.js. Confirm you see undefined printed three times.
  3. Change var i to let i. Run again. Confirm you now see a, b, c.
  4. Add a function runTest(name) that declares const startTime = Date.now() inside the function. Try console.log(startTime) outside the function. Confirm you get a ReferenceError.
  5. Declare const config = { timeout: 1000 }; at the top of the file. Inside runTest, mutate config.timeout = 5000. Print config.timeout after the call — note that const did NOT prevent the mutation.
  6. Stretch: wrap config in Object.freeze({ timeout: 1000 }). Try the same mutation. In strict mode you'll see an error; otherwise the mutation silently fails. Either way, the value stays at 1000.

That ends Chapter 3. You can now write reusable, well-named functions that take parameters, return values, and keep their scope tidy. The next chapter teaches the data structures these functions process — arrays and objects — and the powerful array methods that make test code feel almost like English.

// tip to track lessons you complete and pick up where you left off across devices.