Scope
Scope is the set of rules that decides where a variable is visible and how a name resolves to a value. Every time you declare a variable, JavaScript places it in a particular region of your program; code outside that region simply cannot see it. Understanding scope is what lets you reason about name collisions, predict which value a reference will return, and avoid leaking state into places it does not belong. It is also the foundation that closures are built on.
The kinds of scope
JavaScript has three scopes you create variables in: global, function, and block. They nest inside one another, with the global scope at the outermost level.
Global scope is the outermost region. A variable declared at the top level of a script (or attached to the global object) is reachable from everywhere. In the browser the global object is window; in Node.js it is globalThis.
Function scope is created by every function. Variables declared inside a function — including its parameters and any var declarations — exist only for the life of a call and are invisible outside it.
Block scope is created by any pair of { } — an if, a for, a while, or a bare block. Crucially, only let and const are block-scoped. var ignores blocks and leaks up to the nearest function (or global) scope.
const appName = "DevCraftly"; // global scope
function render() {
const heading = "Welcome"; // function scope
if (true) {
let badge = "new"; // block scope
const VERSION = "2.0"; // block scope
console.log(appName, heading, badge, VERSION);
}
// console.log(badge); // ReferenceError — badge is not visible here
}
render();
Output:
DevCraftly Welcome new 2.0
The var keyword behaves differently — it is not block-scoped, which is a common source of bugs:
function demo() {
if (true) {
var leaked = "I escape the block";
let trapped = "I stay inside";
}
console.log(leaked); // works — var is function-scoped
// console.log(trapped); // ReferenceError — let is block-scoped
}
demo();
Output:
I escape the block
Prefer
constby default andletwhen you must reassign. Avoidvar: its function-scoping and hoisting behavior produce surprises, especially inside loops and conditionals.
The scope chain
Scopes nest, and that nesting forms a chain. When you reference a name, the engine looks in the current scope first; if it is not found there, it walks outward to the enclosing scope, and continues until it reaches the global scope. If the name is never found, you get a ReferenceError. The lookup only ever goes outward — an outer scope can never see into an inner one.
┌─────────────────────────────────────────────┐
│ Global scope appName │
│ ┌────────────────────────────────────────┐ │
│ │ outer() scope userId │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ inner() scope greeting │ │ │
│ │ │ ── can read: greeting, userId, │ │ │
│ │ │ appName (walks outward) │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
const appName = "DevCraftly";
function outer() {
const userId = 42;
function inner() {
const greeting = "Hi";
// inner can reach all three layers
console.log(greeting, userId, appName);
}
inner();
}
outer();
Output:
Hi 42 DevCraftly
Lexical scoping
JavaScript uses lexical (also called static) scoping: a function’s scope chain is determined by where it is written in the source, not by where or how it is called. You can decide what a function can see just by reading the code — you never have to trace the call stack at runtime.
const message = "module level";
function makePrinter() {
const message = "inside makePrinter";
return function print() {
console.log(message); // resolves where print was defined
};
}
const printer = makePrinter();
function elsewhere() {
const message = "inside elsewhere";
printer(); // call site does NOT change what print sees
}
elsewhere();
Output:
inside makePrinter
Even though printer() is invoked from inside elsewhere, it prints "inside makePrinter" because print was defined there. The call site is irrelevant — only the lexical position matters.
Shadowing
When an inner scope declares a variable with the same name as one in an outer scope, the inner declaration shadows the outer one. Within the inner scope the name refers to the inner variable; the outer variable is untouched and reappears once you leave.
const role = "guest";
function authorize() {
const role = "admin"; // shadows the global `role`
console.log(role);
}
authorize();
console.log(role); // outer value is unchanged
Output:
admin
guest
Shadowing is legal and often useful, but accidental shadowing can hide the value you meant to use. Be deliberate, and avoid reusing names across nested scopes unless the shadowing is intentional.
Watch for the temporal dead zone: a
let/constname is shadowed for the entire block, so referencing it before its declaration throws aReferenceErroreven if an outer variable of the same name exists.
Scope comparison
| Aspect | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes (initialized undefined) | Yes (temporal dead zone) | Yes (temporal dead zone) |
| Reassignable | Yes | Yes | No |
| Redeclarable in same scope | Yes | No | No |
Leaks out of { } | Yes | No | No |
Best Practices
- Default to
const, useletwhen reassignment is required, and avoidvarin new code. - Declare variables in the narrowest scope that works — block over function, function over global.
- Keep the global scope clean; treat it as shared, polluting it leads to name collisions across files and libraries.
- Rely on lexical scoping to reason about visibility: read where a function is written, not where it is called.
- Avoid accidental shadowing by giving nested variables distinct, descriptive names.
- Remember the scope chain only searches outward, so design data flow to pass values inward via parameters rather than reaching for outer state.