Scope and closures

Beginner35 min

Understand lexical scope, and use closures to keep state private and to build functions that remember their surroundings.

What you will learn
  • Describe the scope chain of a nested function
  • Explain what a closure is and what it captures
  • Build a counter factory and a function with private state
  • Fix the closure-in-a-loop bug
  • Say what module scope means in an ES module
On this page

Where a variable can be used is decided by where it is written. The fundamentals lesson on scope and lifetime introduces this. JavaScript adds a feature that builds on it, the closure, which is one of the language’s most useful ideas and the cause of many bugs.

Lexical scope

JavaScript uses lexical scope: a function can use the variables of the code around it, as written in the source. When the engine meets a name, it looks in the current scope, then in the scope that encloses it, and so on outwards. This chain ends at the module’s top level.

scope_levels.mjs
const appName = "demo";
function outer() {
const level = "outer function";
function inner() {
const innerValue = "inner function";
console.log(appName, level, innerValue);
}
inner();
try {
console.log(innerValue);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
}
outer();
Output
demo outer function inner function
ReferenceError: innerValue is not defined

inner sees its own variable, the variable of outer, and the module-level appName. The reverse does not work: outer cannot see innerValue, because it belongs to the inner function, so the lookup fails with a ReferenceError.

Shadowing is the same idea with a repeated name. Each inner x hides the outer one:

scope_levels.mjs (continued)
const x = "module";
function shadow() {
const x = "function";
{
const x = "block";
console.log(x);
}
console.log(x);
}
shadow();
console.log(x);
Output
block
function
module

The innermost x wins within its own scope, and each outer x is back as soon as the inner scope ends.

The second half shows shadowing again. The innermost x wins within its own scope, and each outer x is back as soon as the inner scope ends.

Closures

A closure is a function together with the variables it uses from the scope where it was created. The function keeps access to those variables even after the code that created them has finished.

closure_counter.mjs
function makeCounter(start = 0) {
let count = start;
return () => {
count += 1;
return count;
};
}
const a = makeCounter();
const b = makeCounter(100);
console.log(a(), a(), a());
console.log(b(), b());
console.log(a());
Output
1 2 3
101 102
4

makeCounter has finished running by the time the counter is used, but the returned arrow function still reads and updates count. Each call to makeCounter creates a new count, so the counters a and b are independent. Nothing outside can reach count except through the returned function.

A closure captures the variable, not a copy of its value at that time:

closure_live.mjs
let message = "first";
const show = () => message;
console.log(show());
message = "second";
console.log(show());
function makeAdder(amount) {
return (n) => n + amount;
}
const addFive = makeAdder(5);
const addTen = makeAdder(10);
console.log(addFive(1), addTen(1));
Output
first
second
6 11

show returns the current message every time it runs, so it sees the change to "second". makeAdder shows the other common use of closures: building a function that has been given some configuration. addFive and addTen each remember their own amount.

Private state

JavaScript objects have no hidden properties unless you use the class feature for it, which a later module covers. A closure gives a simple way to hide data: keep it in local variables and hand out only the functions that may use it.

private_state.mjs
function createAccount(opening) {
let balance = opening;
const history = [];
return {
deposit(amount) {
balance += amount;
history.push(`+${amount}`);
},
withdraw(amount) {
if (amount > balance) {
throw new RangeError("insufficient funds");
}
balance -= amount;
history.push(`-${amount}`);
},

The two read-only methods come next. getHistory returns a copy of the list:

private_state.mjs (continued)
getBalance() {
return balance;
},
getHistory() {
return [...history];
},
};
}

Now use the account from outside:

private_state.mjs (continued)
const account = createAccount(100);
account.deposit(50);
account.withdraw(30);
console.log(account.getBalance(), account.getHistory());
console.log(account.balance, account.history);
try {
account.withdraw(1000);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
Output
120 [ '+50', '-30' ]
undefined undefined
RangeError: insufficient funds

balance and history exist only inside createAccount. Outside code can call deposit, withdraw and the getters, but account.balance is undefined: there is no such property. The rule that a balance cannot go negative can therefore not be bypassed. getHistory returns a copy, so callers cannot modify the real list either.

Closures in loops

The loop bug from the let lesson is a closure bug. Functions created in a loop capture the loop’s variable.

closure_loop_fix.mjs
// Bug: every handler reports the same index.
const buggy = [];
for (var i = 0; i < 3; i++) {
buggy.push(() => `handler ${i}`);
}
console.log(buggy.map((h) => h()));
Output
[ 'handler 3', 'handler 3', 'handler 3' ]

The first fix is to declare the loop variable with let:

closure_loop_fix.mjs (continued)
// Fix 1: let gives each pass its own variable.
const fixed1 = [];
for (let i = 0; i < 3; i++) {
fixed1.push(() => `handler ${i}`);
}
console.log(fixed1.map((h) => h()));
Output
[ 'handler 0', 'handler 1', 'handler 2' ]

The second fix is a factory function that takes the value as a parameter:

closure_loop_fix.mjs (continued)
// Fix 2: a factory function captures the value as a parameter.
function makeHandler(index) {
return () => `handler ${index}`;
}
const fixed2 = [];
for (var j = 0; j < 3; j++) {
fixed2.push(makeHandler(j));
}
console.log(fixed2.map((h) => h()));
Output
[ 'handler 0', 'handler 1', 'handler 2' ]

With var there is one i for the whole loop, and every handler reads it after the loop has ended. There are two ways to fix it. Use let, which creates a new variable per pass. Or call a function that takes the value as a parameter, as makeHandler does: each call has its own index. The second pattern works with var too, which is how this was solved before let existed.

Module scope

In a file that Node treats as an ES module, such as a .mjs file, the top level is the module scope. Variables declared there are private to that file. They are not properties of the global object, and other files see them only if you export them (a later module covers export and import).

module_scope.mjs
var topLevelVar = "var";
const topLevelConst = "const";
console.log(typeof globalThis.topLevelVar);
console.log(typeof globalThis.topLevelConst);
console.log(typeof globalThis.console);
console.log(this);
Output
undefined
undefined
object
undefined

globalThis is the global object, and console lives there. The var and const of the module do not. At the top level of a module, this is undefined. In a classic script, such as a <script> tag without type="module" in a browser, top-level var and function declarations do become properties of the global object. Modules avoid that clutter.

Common pitfalls

  • Expecting a closure to capture a value. It captures the variable, so later changes are visible.
  • Creating functions in a var loop. All of them share one variable.
  • Relying on closures for secrecy without care. Returning the internal array itself, rather than a copy, leaks the state.
  • Accidental globals. Assigning to an undeclared name is a ReferenceError in modules, which is a good thing. Declare every variable.
  • Holding on to large data. A closure keeps its variables alive as long as the function exists. Do not capture a huge array if you only need one value from it.

Exercises

ExerciseCounter factory

Write makeCounter({ start, step }) that returns an object with next() and reset(). next adds step to the counter and returns it. reset goes back to start. The defaults are 0 and 1. Create a counter evens that steps by 2, and another, fromTen, that starts at 10, and check that they do not interfere. The output should be 2 4 6, then 2 after a reset, then 11 4 for fromTen.next() and evens.next().

Show solution
counter_factory.mjs
function makeCounter({ start = 0, step = 1 } = {}) {
let value = start;
return {
next() {
value += step;
return value;
},
reset() {
value = start;
},
};
}
const evens = makeCounter({ step: 2 });
console.log(evens.next(), evens.next(), evens.next());
evens.reset();
console.log(evens.next());
const fromTen = makeCounter({ start: 10 });
console.log(fromTen.next(), evens.next());
Output
2 4 6
2
11 4

Each call to makeCounter creates its own value, which both methods close over. evens goes 2, 4, 6, resets, and then gives 2 again. fromTen is separate and gives 11, and evens continues to give 4 afterwards.

ExerciseA limited function

Write makeLimiter(max, action). It returns a function that calls action with the same arguments for the first max calls, and returns the string "limit reached" for every call after that. Each limiter counts on its own. Check it like this:

  • A limiter with max 2 around an action that returns the text hello and the name, called with "Ada", "Grace" and "Linus", prints hello Ada, hello Grace and limit reached.
  • A second limiter with max 1 around an action that doubles a number, called as double(21) and double(5), together with one more call to the first limiter, prints 42 limit reached limit reached on one line.
Show solution
limiter.mjs
function makeLimiter(max, action) {
let used = 0;
return (...args) => {
if (used >= max) {
return "limit reached";
}
used += 1;
return action(...args);
};
}
const greet = makeLimiter(2, (name) => `hello ${name}`);
console.log(greet("Ada"));
console.log(greet("Grace"));
console.log(greet("Linus"));
const double = makeLimiter(1, (n) => n * 2);
console.log(double(21), double(5), greet("again"));
Output
hello Ada
hello Grace
limit reached
42 limit reached limit reached

used lives in the closure, so each call to makeLimiter has its own count. The returned function checks the count first, and only then runs action, passing along whatever arguments it received.

ExercisePredict the closures

Read this program and write down what you expect it to print before you run it. Two functions both read a variable called x, but they read different ones. The expected output is 2 10 and then 3 10.

let x = 1;
const getOuter = () => x;
function make() {
let x = 10;
return () => x;
}
const getInner = make();
x = 2;
console.log(getOuter(), getInner());
x = 3;
console.log(getOuter(), getInner());
Show solution
closure_predict.mjs
let x = 1;
const getOuter = () => x;
function make() {
let x = 10;
return () => x;
}
const getInner = make();
x = 2;
console.log(getOuter(), getInner());
x = 3;
console.log(getOuter(), getInner());
Output
2 10
3 10

getOuter closes over the module-level x, so it sees each later assignment. getInner closes over the x inside make, which is a different variable that nothing changes, so it always gives 10. A closure sees the variable in the scope where the function was written, not any variable with the same name.

Summary

  • A function can read variables from the scopes around where it was written, outward to the module level.
  • A closure keeps those variables alive and shared, so it sees later changes.
  • Each call of a factory creates new variables, which gives independent counters and private state.
  • A var loop shares one variable between all closures. Use let, or pass the value through a function.
  • Module-level variables in an ES module are private to the file.

Next: functions that take and return other functions.