Skip to main content
Advertisement

4.2 Scope and Closures

Scope and closures are essential concepts for deeply understanding JavaScript. Mastering closures lets you freely implement data hiding, state management, and the module pattern.


Lexical Scope

JavaScript uses lexical scope (static scope). The scope is determined by where the function is defined, not where it is called.

const x = "global x";

function outer() {
const x = "outer x";

function inner() {
// inner is defined inside outer,
// so it has access to outer's x (regardless of where it's called)
console.log(x); // "outer x"
}

return inner;
}

const fn = outer();
fn(); // "outer x" — even after outer() has finished executing

Comparison with dynamic scope

// Lexical scope (JavaScript's approach)
const value = "global";

function showValue() {
console.log(value); // always "global" (based on definition time)
}

function caller() {
const value = "local";
showValue(); // "global" — based on definition location, not call site
}

caller();

Scope Chain

When looking up a variable, JavaScript starts from the current scope and moves outward sequentially. This is called the scope chain.

const level1 = "Level 1 global";

function outer() {
const level2 = "Level 2 outer";

function middle() {
const level3 = "Level 3 middle";

function inner() {
const level4 = "Level 4 inner";

// Scope chain order: inner → middle → outer → global
console.log(level4); // found in current scope
console.log(level3); // found in middle scope
console.log(level2); // found in outer scope
console.log(level1); // found in global scope
}

inner();
}

middle();
}

outer();

Variable Shadowing

const name = "global name";

function greet() {
const name = "local name"; // shadows global name
console.log(name); // "local name"
}

greet();
console.log(name); // "global name" (global unchanged)

Closure

A closure is an inner function that can still access the outer function's variables after the outer function has finished executing. The inner function "closes over" the environment (lexical environment) where it was defined.

function makeCounter(initialValue = 0) {
let count = initialValue; // this variable is remembered by the closure

return {
increment() { return ++count; },
decrement() { return --count; },
reset() { count = initialValue; return count; },
value() { return count; },
};
}

const counter = makeCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.value()); // 11
counter.reset();
console.log(counter.value()); // 10

// Independent closure instances
const counter2 = makeCounter(0);
console.log(counter2.increment()); // 1
console.log(counter.value()); // 10 (independent from counter2)

Closure Usage Patterns

Pattern 1: Data Hiding (Private Variables)

function createBankAccount(owner, initialBalance) {
// Private variables — inaccessible from outside
let balance = initialBalance;
const transactions = [];

function recordTransaction(type, amount) {
transactions.push({
type,
amount,
balance,
date: new Date().toISOString(),
});
}

return {
get owner() { return owner; },

deposit(amount) {
if (amount <= 0) throw new Error("Deposit amount must be greater than 0");
balance += amount;
recordTransaction("deposit", amount);
return this;
},

withdraw(amount) {
if (amount > balance) throw new Error("Insufficient balance");
balance -= amount;
recordTransaction("withdrawal", amount);
return this;
},

getBalance() { return balance; },

getHistory() { return [...transactions]; }, // return a copy
};
}

const account = createBankAccount("Alice", 10000);
account.deposit(5000).withdraw(3000);
console.log(account.getBalance()); // 12000
console.log(account.getHistory().length); // 2

// balance is inaccessible directly
// account.balance; // undefined

Pattern 2: Factory Function

// Function factory capturing configuration via closure
function createMultiplier(factor) {
return (number) => number * factor;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
const tenTimes = createMultiplier(10);

console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(tenTimes(5)); // 50

// Practical: unit converter
function createConverter(fromUnit, toUnit, rate) {
return {
convert: (value) => +(value * rate).toFixed(4),
toString: () => `${fromUnit}${toUnit}${rate})`,
};
}

const kmToMile = createConverter("km", "mile", 0.621371);
const celsiusToFahrenheit = createConverter("°C", "°F", 1.8);

console.log(kmToMile.convert(100)); // 62.1371
console.log(`Reference: ${kmToMile}`); // Reference: km → mile (×0.621371)

Pattern 3: Module Pattern

// IIFE + closure to implement a module (pre-ES modules pattern)
const UserStore = (function() {
// Private state
const users = new Map();
let nextId = 1;

// Private function
function validate(user) {
if (!user.name?.trim()) throw new Error("Name is required");
if (!user.email?.includes("@")) throw new Error("Enter a valid email");
}

// Public API
return {
add(userData) {
validate(userData);
const id = nextId++;
const user = { id, ...userData, createdAt: new Date() };
users.set(id, user);
return user;
},

get(id) {
return users.get(id) ?? null;
},

getAll() {
return [...users.values()];
},

remove(id) {
return users.delete(id);
},

get size() { return users.size; },
};
})();

const alice = UserStore.add({ name: "Alice", email: "alice@example.com" });
const bob = UserStore.add({ name: "Bob", email: "bob@example.com" });

console.log(UserStore.size); // 2
console.log(UserStore.get(alice.id).name); // "Alice"

Pattern 4: Memoization

function memoize(fn) {
const cache = new Map(); // cache persisted via closure

return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit: ${key}`);
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}

// Fibonacci with recursion + memoization
const fibonacci = memoize(function fib(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});

console.log(fibonacci(40)); // 102334155 (fast!)
console.log(fibonacci(40)); // Cache hit: [40]

Closures and Loops: var vs let

This is one of the classic closure pitfalls.

// ❌ Using var — all closures reference the same variable
const funcsVar = [];
for (var i = 0; i < 3; i++) {
funcsVar.push(function() { return i; });
}

console.log(funcsVar[0]()); // 3 (expected: 0)
console.log(funcsVar[1]()); // 3 (expected: 1)
console.log(funcsVar[2]()); // 3 (expected: 2)
// var i is 3 after the loop ends, and all closures share the same i

// ✅ Using let — new block scope per iteration
const funcsLet = [];
for (let i = 0; i < 3; i++) {
funcsLet.push(function() { return i; });
}

console.log(funcsLet[0]()); // 0 ✓
console.log(funcsLet[1]()); // 1 ✓
console.log(funcsLet[2]()); // 2 ✓

// ✅ IIFE to fix var problem (legacy code pattern)
const funcsIIFE = [];
for (var j = 0; j < 3; j++) {
funcsIIFE.push((function(capturedJ) {
return function() { return capturedJ; };
})(j));
}

console.log(funcsIIFE[0]()); // 0 ✓

Closures and Memory Leaks

If a closure references large data, the garbage collector cannot reclaim it.

// ❌ Risk of memory leak
function createLeakyClosure() {
const largeData = new Array(1_000_000).fill("data"); // ~8MB

return function() {
// largeData is unused but the closure keeps a reference
return "result";
};
}

// ✅ Release unnecessary references
function createSafeClosure() {
let largeData = new Array(1_000_000).fill("data");
const summary = largeData.length; // extract only what's needed
largeData = null; // release the large data reference

return function() {
return `Processed ${summary} items`;
};
}

const safe = createSafeClosure();
console.log(safe()); // Processed 1000000 items

Practical Example: Debounce and Throttle

Event optimization utilities using closures.

// Debounce: only execute the last call in a series
function debounce(fn, delay) {
let timeoutId; // timer ID persisted via closure

return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}

// Throttle: execute at most once per interval
function throttle(fn, interval) {
let lastTime = 0; // last execution time persisted via closure

return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}

// Usage example
const onSearch = debounce((query) => {
console.log(`Searching: ${query}`);
}, 300);

const onScroll = throttle(() => {
console.log(`Scroll position: ${window?.scrollY ?? 0}`);
}, 100);

// Continuous calls — only the last executes
onSearch("J");
onSearch("Ja");
onSearch("Jav");
onSearch("Java"); // After 300ms, only "Searching: Java" runs

Pro Tips

Tip 1: WeakRef for closure memory optimization

// WeakRef: a weak reference that can be garbage collected
function createWeakCache() {
const cache = new Map();

return {
set(key, value) {
cache.set(key, new WeakRef(value));
},
get(key) {
const ref = cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
cache.delete(key); // remove from cache if GC'd
return undefined;
}
return value;
},
};
}

Tip 2: Immutable config pattern with closure

// Immutable configuration object using Object.freeze + closure
const Config = (function() {
const _config = Object.freeze({
API_URL: "https://api.example.com",
MAX_RETRIES: 3,
TIMEOUT: 5000,
});

return {
get: (key) => _config[key],
getAll: () => ({ ..._config }),
};
})();

console.log(Config.get("API_URL")); // "https://api.example.com"

Tip 3: Combining generators with closures

function createIdGenerator(prefix = "id") {
let current = 0;

return {
next: () => `${prefix}_${++current}`,
peek: () => `${prefix}_${current + 1}`,
reset: () => { current = 0; },
};
}

const userIdGen = createIdGenerator("user");
const postIdGen = createIdGenerator("post");

console.log(userIdGen.next()); // "user_1"
console.log(userIdGen.next()); // "user_2"
console.log(postIdGen.next()); // "post_1"
Advertisement