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 (anif,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 definedtoken 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 definedLoop 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'tIn 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 variableIn 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 clet 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
- – 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:
- Use
constby default. - Switch to
letonly when you genuinely need to reassign — counters, accumulators, swapping values. - Never use
var. If a linter still allows it, configure ESLint'sno-varrule.
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
constmakes 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
varandletin the same scope. Hoisting interactions get weird quickly. Picklet/constand 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(orfor...of) is the fix.
🎯 Practice task
See scope misbehave, then fix it. 15-20 minutes.
- In your
js-for-qafolder, createscope.js. - Paste the buggy
varloop withsetTimeoutfrom this lesson. Run it withnode scope.js. Confirm you seeundefinedprinted three times. - Change
var itolet i. Run again. Confirm you now seea,b,c. - Add a function
runTest(name)that declaresconst startTime = Date.now()inside the function. Tryconsole.log(startTime)outside the function. Confirm you get aReferenceError. - Declare
const config = { timeout: 1000 };at the top of the file. InsiderunTest, mutateconfig.timeout = 5000. Printconfig.timeoutafter the call — note thatconstdid NOT prevent the mutation. - Stretch: wrap
configinObject.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 at1000.
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.