Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B4
4 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB4 · Part 1 · Beginner

Scope

Which names a line of code can see, why that is decided by where you wrote it, and how JavaScript finds a name — for reading and for writing.

15 min read1 diagram11 sections2 exercises
4 of 41

In this chapter

  • Scope is a question about names
  • Every kind of scope JavaScript has
  • Lexical: decided by where you wrote it
  • The lookup, step by step
  • Shadowing

and 6 more

about 15 min
11On this page
  1. Scope is a question about names
  2. Every kind of scope JavaScript has
  3. Lexical: decided by where you wrote it
  4. The lookup, step by step
  5. Shadowing
  6. Writing walks the chain too
  7. The global scope, and why it is special
  8. Scope as a wall: keeping names private
  9. Things that bend scope — and why not to use them
  10. Scope is not lifetime
  11. The traps, collected

Scope is a question about names

Every time a line of code uses a name — total, user, render — the engine has to answer one question: which binding does this name mean, here? The scope of a binding is the region of source code where that name reaches it. That is the whole definition, and it has one important word in it: source code. Scope is a property of the text you wrote, not of the program while it runs.

Three words get mixed up constantly, and keeping them apart is half of understanding this chapter:

Word The question it answers Decided by
Scope Where can this name be seen? where the code is written
Lifetime How long does the box exist? whether anything can still reach it
this Which object is this call about? how the function was called

Only the first row is this chapter. Lifetime comes back at the end, because it is where scope hands over to closures; this has a chapter of its own, and it is the one thing on the list that is not decided by where you wrote it.

Every kind of scope JavaScript has

Scopes nest inside each other like boxes. Here is every box the language can make, from the outside in:

GLOBAL — var, function declarations (the window object) SCRIPT (let / const, shared by classic scripts) or MODULE (one per file) FUNCTION — parameters, var, everything declared in its body BLOCK — let / const / class inside { } if, for, while, try, catch, a bare { }, one per switch code in here can see every box around it — never into a box inside it
Four sizes of box. Lookup only ever moves outward, through the walls, from the box the code is in.
Scope Created when Holds
Global once, when the page or process starts top-level var and function declarations, as properties of window / globalThis
Script with the global one, and shared by every classic <script> top-level let, const, class — not on window, but still shared
Module once per module file everything at the top of that file; only export lets anything out
Function on every call parameters, arguments, and every var in the body, however deep
Block every time execution enters the braces let, const, class — and in strict code, function declarations

And three small ones that are easy to miss, because nobody writes braces for them:

  • A catch parameter is scoped to its catch block. After the try/catch, err does not exist.
  • A named function expression — and a named class expression — gets a tiny scope holding just its own name, visible inside and nowhere else: const C = class Named { ... } can say Named in its methods; the outside world only has C.
  • Parameters with defaults get a scope between the outer one and the body. It rarely matters, and when it does it is surprising — the intermediate chapter has the example.

Lexical: decided by where you wrote it

JavaScript's scope is lexical (also called static). Which scopes a function can see is fixed by where the function sits in the source — which braces it is inside — and nothing that happens at runtime changes it. Test that claim with the case where it matters most: a function called from somewhere that has a variable of the same name.

const who = "global";

function report() {
  return who;
}

function caller() {
  const who = "caller";
  return report();       // called from inside here...
}

console.log(caller());   // what happens?

"global". report was written at the top level, so its outer scope is the top level — full stop. It is called from inside caller, but calling a function does not lend it the caller's variables. The who inside caller is invisible to it.

The alternative has a name: dynamic scope, where a function sees the variables of whoever called it. Bash works that way, and so did early Lisps. In a dynamically scoped language the answer above would be "caller" — and you could not know what report returns without knowing every place that might call it. Lexical scope is what lets you read a function on its own and know what each name means.

Say it like this → "Scope is lexical: a function sees the variables around where it was written, not where it was called. The only thing in JavaScript that looks at the call is this."

Where the link actually lives

"The function remembers where it was written" is not a figure of speech. When the engine creates a function object, it stores a hidden reference to the scope that was current at that moment — the spec calls the slot [[Environment]]. Every later call builds a fresh scope for the body, and sets that new scope's outer reference to the stored one. Not to the caller's scope: to the stored one.

That single rule gives you everything else: lexical lookup, the reason report above cannot see caller's who, and — once a function is returned and called somewhere far away — closures, which are nothing more than this link still working. Arrow functions go one step further and look up this through the same link, which is why they keep the this of the code around them.

The lookup, step by step

When a line uses a name, the engine checks the innermost scope first. If the name is declared there, it stops. If not, it follows the outer reference one level out and checks again — and keeps going until it finds a declaration or runs out of scopes. Step through four lookups from one line:

Four names, one line — walking the chain outward
Looking for
The chain, innermost first
Result

Dry run: resolving four names from inside inner()

Same snippet as the demo above, laid out as a static trace — one row per name, per scope checked.

NameChecked in inner()Checked in outer()Checked at top levelFound inResult
age✓ found——inner()29
name✗✓ found—outer(), one hop out"Ana"
city✗✗✓ foundtop level, two hops out"Pune"
zip✗✗✗nowhere — top level has no outer referenceReferenceError: zip is not defined

Every lookup starts over from the innermost scope, inner() — there is no shortcut from "found name one hop out last time" to the next name's search. And the search always stops at the first match, which is exactly the mechanism behind shadowing.

Three rules fall out of that walk:

  • Outward only. An outer scope can never see into an inner one. A variable declared inside a function is invisible to the code that called it.
  • Never sideways. Two functions written side by side cannot see each other's locals, even if one calls the other. Neither is inside the other.
  • The first match wins. The search stops at the nearest declaration, which is the whole mechanism behind shadowing.
function a() { const onlyA = 1; b(); }
function b() { return typeof onlyA; }   // "undefined" — a's locals are not on b's chain

Shadowing

Declare a name in an inner scope that already exists further out, and the inner one shadows the outer: from inside, the search stops at the inner declaration and never reaches the outer one. The outer binding is not changed, overwritten or touched — it is just unreachable by that name from in there.

let x = "outer";
function show() {
  let x = "inner";
  console.log(x);
}
show();
console.log(x);   // what happens?

"inner", then "outer" — two separate bindings that happen to share a name. This is also why nested loops can both use let i: each inner i shadows the outer one for the length of its block.

Once shadowed, the outer name is gone from that scope

There is no syntax for "the x one level out". With one exception, and only for the global scope — and even that exception depends on the keyword:

var fromVar = "global var";
let fromLet = "global let";

function look() {
  const fromVar = "local";
  const fromLet = "local";
  console.log(globalThis.fromVar);   // what happens?
  console.log(globalThis.fromLet);   // and here?
}
look();

"global var", then undefined. A top-level var is a property of the global object, so you can still reach it as globalThis.fromVar (or window.fromVar). A top-level let lives in the script scope, which is not an object — once it is shadowed, it is truly out of reach. Run this as a module and both are undefined, because nothing in a module lands on the global object.

What you can shadow — more than you would guess

function odd() {
  let undefined = 5;     // legal. undefined is a global property, not a keyword
  return undefined;      // 5
}

function greet(name) {
  if (name) {
    let name = "inner";  // legal — a new block, so it shadows the parameter
  }
}

undefined, NaN and Infinity are ordinary names in the global scope, so any inner scope can shadow them. (Nobody does this on purpose; you will meet it in minified code and in trick questions.) What you cannot do is redeclare a parameter with let in the function's own top-level scope — that is a redeclaration, not shadowing, and it is a SyntaxError.

Shadowing is legal and sometimes clean — a short-lived i in a nested loop. It is also a steady source of bugs: you meant the outer user and got the inner one, or you "updated" a variable that was a different variable all along. ESLint's no-shadow rule flags it, and many teams turn it on. The nastiest version, where the inner name is still in its TDZ, is in var, let and const.

Writing walks the chain too

Everything so far was about reading a name. Assignment uses exactly the same search: count = 5 finds the nearest declared count and writes there — which may be several scopes out.

let count = 0;
function outer() {
  function inner() {
    count = 5;          // no declaration here or in outer — writes the top-level count
  }
  inner();
}
outer();
console.log(count);     // 5

That is how an inner function updates shared state, and it is also how one function quietly changes a variable another function was relying on. The difference between the two is whether you meant it.

The interesting case is when the search finds nothing. Reading an undeclared name is always a ReferenceError. Writing to one depends on the mode:

function tally() {
  let total = 0;
  totl = 10;            // a typo — no declaration anywhere on the chain
  return total;
}
tally();
console.log(typeof totl);   // what happens?

In a sloppy script: "number". The failed search reached the top and, instead of complaining, created a brand-new global called totl. The function returns 0, the typo lives on as a global forever, and nothing tells you. In strict mode — which includes every ES module and every class body — the same line throws ReferenceError: totl is not defined, which is what you want. This one behaviour is most of the reason "use strict" exists.

And when the search finds a const, the write reaches it and fails there: TypeError: Assignment to constant variable, even from three functions away.

The global scope, and why it is special

Every chain ends at the same place, so everything you put there is visible to every piece of code on the page — yours, your libraries', your analytics script's, and every browser extension that injects into the page. That is the real argument against globals: not style, but that every global is a name any other code can read, overwrite or collide with.

The top level of a classic script is split in two, and var, let and const showed both halves in action:

  • an object half — the global object itself, called window in browsers and globalThis everywhere — holding top-level vars, function declarations, and everything the browser defines;
  • a declarative half beside it, holding top-level let, const and class. Not on window, but still one shared scope across every classic script on the page.

Lookup checks the declarative half first, then the object half. That is why a top-level let name works, even though window.name already exists — your let is found first and hides it.

Scope as a wall: keeping names private

Because nothing can see into a scope from outside, a scope is the simplest privacy tool JavaScript has. The history of how people used it is a tour of the language:

// 1. Before 2015: a function scope, called immediately (an IIFE)
var counter = (function () {
  var count = 0;                      // invisible outside this function
  return { inc: function () { return ++count; } };
})();

// 2. With let and const: any block is enough for temporary names
{
  const temp = expensiveSetup();
  window.app = build(temp);           // temp is gone after the brace
}

// 3. Today: a module. The file IS the wall; export is the only door
let count = 0;                        // private to this file
export function inc() { return ++count; }

The IIFE — Immediately Invoked Function Expression — was everywhere before modules because a function was the only thing that made a scope: wrap the whole file in one, return just what should be public, and nothing else leaked into the shared global scope. You will still meet it in older code, in bundler output, and in any snippet that has to paste safely into a page. For new code, modules do the same job with no ceremony.

The same trick was the old fix for the var loop: one IIFE per iteration, taking the current value as a parameter, so each callback got a scope — and a box — of its own.

for (var i = 0; i < 3; i++) {
  (function (j) {                          // a new function scope per pass...
    setTimeout(function () { console.log(j); }, 0);
  })(i);                                   // ...holding this pass's value
}
// 0 1 2 — what let now does for you, by hand

The IIFE in step 1 is also the first closure most people ever wrote: inc keeps using count after the function that declared it has returned. Why that works is the closures chapter.

Things that bend scope — and why not to use them

Lexical scope's big promise is that you can work out every name from the source alone. Three features break that promise. They are worth recognising, mostly so you can explain why nobody uses them.

function f() {
  eval("var added = 1");    // direct eval, sloppy mode: adds a var to f's scope
  return typeof added;      // "number" — a name that appears nowhere in f's source
}

function g(settings) {
  with (settings) {         // puts an OBJECT on the scope chain
    return size;            // settings.size? or an outer size? depends on the object
  }
}

function h() {
  const secret = "local";
  return new Function("return secret")();   // ReferenceError, or a global secret
}
  • Direct eval runs a string as code in the current scope: it can read your locals and, in sloppy mode, add new vars to them. In strict mode it gets a scope of its own, and an indirect call like (0, eval)(code) runs in the global scope instead.
  • with adds an object's properties to the chain, so the same name means a property or a variable depending on what the object holds at runtime. It is a SyntaxError in strict mode.
  • new Function(...) goes the other way: the function it builds is always scoped to the global scope, whatever scope you created it in. It cannot see your locals at all.

This is also a performance story. Because scope is lexical, engines work out before running where almost every name lives, so a lookup is typically "slot 3 of the scope two levels up" rather than a search by name. eval and with make that impossible — any name might resolve anywhere — so engines fall back to slow, careful lookups for any function that contains them.

Scope is not lifetime

Back to the table at the top. Scope is about the source; lifetime is about the running program. They usually line up, which is why people merge them — but all four combinations happen:

The box exists The box does not exist (yet, or any more)
In scope the normal case — a variable in use a let in its TDZ: visible to the lookup, not usable yet
Out of scope a variable a closure still holds — no code outside can name it, but it is alive a block's variables after its closing brace, once nothing refers to them

The bottom-left cell is the interesting one, and it has a chapter of its own. Every function that is still reachable keeps its [[Environment]] link, and whatever that link points at cannot be thrown away:

function make() {
  const secret = "kept";      // out of scope for everyone outside make()
  return () => secret;
}
const read = make();          // make() has returned...
read();                       // "kept" — ...and secret is still alive

secret is out of every scope except the arrow's, and alive for as long as read is. That is a closure, and Closures is the whole story.

The traps, collected

  • Expecting a function to see its caller's variables — scope is lexical. It sees where it was written, never where it was called.
  • A typo in an assignment — in sloppy code it creates a global silently. Use modules or "use strict" and it throws.
  • An inner function "updating" a variable that turns out to be a shadowing inner one — or, the other way round, writing to an outer variable you meant to declare locally.
  • Reaching a shadowed global with window.x — works for a var, gives undefined for a let or const.
  • Treating the global scope as private to your script — every script on the page, and every extension, shares it.
  • new Function inside a function — it cannot see your locals; it only ever sees the global scope.
Say it like this → "Scope is lexical: every function stores the scope it was created in, and each call's scope points outward to it. A lookup walks that chain from the inside out and stops at the first declaration — that is shadowing. Writes walk the same chain, and if they find nothing, sloppy mode makes a global and strict mode throws."
Interview answer, one sentence

"JavaScript scope is lexical, decided by where you write the code rather than where you call it, and a name lookup walks outward through the enclosing scopes until it finds a match — which is why an inner variable shadows an outer one, and why assigning to an undeclared name in sloppy mode quietly creates a global."

Before you move on
  • Name every kind of scope JavaScript has, from global down to a bare block, and say what triggers each one being created.
  • Walk a name lookup outward, scope by scope, for a nested function, and explain why the search never looks inward or sideways.
  • Explain why report() returns "global" even when called from inside caller(), which has its own who.
  • Predict what a typo'd assignment does in sloppy mode versus strict mode, and say which one you actually want.
  • Explain the difference between "out of scope" and "does not exist," using a closure as the example where the two come apart.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsWhich scope does this name mean?beginner
  • 8 testsWhere does this assignment land?beginner
All 35 for this level →
B4

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B3Previous · B3var, let and const
  2. B4you are here
  3. Next · B5One thread, one stackWhy JavaScript can only do one thing at a time — and how it still does ten.10 min read

01On this page

11 sections · 0%

  1. 01Scope is a question about names
  2. 02Every kind of scope JavaScript has
  3. 03Lexical: decided by where you wrote it
  4. 04The lookup, step by step
  5. 05Shadowing
  6. 06Writing walks the chain too
  7. 07The global scope, and why it is special
  8. 08Scope as a wall: keeping names private
  9. 09Things that bend scope — and why not to use them
  10. 10Scope is not lifetime
  11. 11The traps, collected

[] chapters · t top