If you've written JavaScript for a few months, you've probably bumped into a variable mysteriously available inside a function, or captured state that persists across calls. That's scope and closures at work—two interconnected concepts that unlock some of the most powerful and elegant patterns in JavaScript and TypeScript. Understanding how they work is the difference between code that feels fragile and code that's predictable and maintainable.
What are scope and closures?
At their core, scope determines which variables are accessible at any point in your code. Closures are functions that retain access to variables from their surrounding scope, even after that outer function has finished executing. They're not separate features—closures are a natural consequence of how JavaScript's scope system works.
Every time you write a function inside another function, or a function inside any block, you're already working with closures. The engine automatically preserves access to outer variables. Once you understand why and how that happens, you'll spot opportunities to use closures intentionally, and you'll avoid subtle bugs caused by unexpected variable capture.
Lexical scope—scope is decided by where code is written, not where it's called
JavaScript uses lexical scope, meaning the scope of a variable is determined by its position in the source code. Where you call a function doesn't matter; what matters is where the function is defined.
Consider this scenario from a real-world CRM system I built. You might have authentication logic at the top level and need to check permissions in various handler functions:
const apiKey = 'secret-123';
const userRole = 'admin';
function validateRequest() {
const timestamp = Date.now();
function checkAuth() {
// checkAuth can access apiKey, userRole, and timestamp
// because it's defined inside validateRequest's scope
console.log(`Auth check at ${timestamp} for role: ${userRole}`);
}
checkAuth();
}
validateRequest();
Notice that checkAuth can see timestamp, userRole, and apiKey. This isn't magic—the engine looks at where checkAuth was written. It's nested inside validateRequest, which is nested in the global scope. So it has three layers of scope to search: its own, validateRequest's, and the global scope.
If you call checkAuth from somewhere else entirely, it still has the same scope chain. The call site doesn't change what variables it can see.
const checkAuth = validateRequest();
// Even if I call checkAuth from across the codebase,
// it still remembers the timestamp and userRole from where it was created.
This is why lexical scope is predictable. You can trace the scope chain by reading the indentation and nesting in your source file.
Closures—inner functions keep access to outer variables
A closure is formed when an inner function references variables from an outer scope and is called (or used) outside that outer scope. The function "closes over" those variables, keeping them alive and accessible.
The classic example is a counter. This is the pattern I've used in state management modules and event systems:
function counter() {
let count = 0;
return () => ++count;
}
const next = counter();
console.log(next()); // 1
console.log(next()); // 2
console.log(next()); // 3
What's happening here? When counter() executes, it creates a local variable count and returns an arrow function. Normally, when a function finishes, its local variables are garbage collected. But here, the returned function has a reference to count, so the engine keeps it alive. Every time you call next(), it increments and returns the same count variable.
Each call to counter() creates a new, independent closure:
const next1 = counter();
const next2 = counter();
console.log(next1()); // 1
console.log(next2()); // 1
console.log(next1()); // 2
console.log(next2()); // 2
The closure captures the specific count variable from that execution of counter(). next1 and next2 have separate closures.
Here's a more complex example from an e-commerce order system I worked on:
function createOrder(orderId: string, items: string[]) {
let status = 'pending';
let appliedDiscount = 0;
return {
applyDiscount: (percent: number) => {
appliedDiscount = percent;
console.log(`Applied ${percent}% discount`);
},
ship: () => {
status = 'shipped';
console.log(`Order ${orderId} shipped with ${items.length} items`);
},
getStatus: () => status,
getDiscount: () => appliedDiscount
};
}
const order = createOrder('ORD-123', ['item1', 'item2']);
order.applyDiscount(10);
order.ship();
console.log(order.getStatus()); // 'shipped'
console.log(order.getDiscount()); // 10
Each method returned by createOrder closes over orderId, items, status, and appliedDiscount. These variables are shared across all methods on that order object. Modify status in ship(), and getStatus() sees the change immediately because they're operating on the same captured variable.
Practical uses—private state, factories, memoization, and event handlers
Closures solve real problems. Let me walk through the most common patterns.
Private state
JavaScript didn't have a private keyword for classes until recently. Closures were the only way to truly hide data:
function createUser(username: string, password: string) {
// password is private; no method exposes it
let sessionToken: string | null = null;
return {
login: (inputPassword: string) => {
if (inputPassword === password) {
sessionToken = Math.random().toString(36);
return sessionToken;
}
return null;
},
getUsername: () => username,
isLoggedIn: () => sessionToken !== null
};
}
const user = createUser('alice', 'mypassword');
user.login('mypassword'); // returns token
console.log(user.isLoggedIn()); // true
// There's no way to read the password directly
The password is captured in the closure but never exposed. This is more secure than storing it as a property on the returned object.
Factory functions
Closures enable factories that produce customized functions. I've used this pattern for API client builders in SaaS platforms:
function createApiClient(baseUrl: string, apiKey: string) {
return {
get: async (endpoint: string) => {
const response = await fetch(`${baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
return response.json();
},
post: async (endpoint: string, data: any) => {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(data)
});
return response.json();
}
};
}
const client = createApiClient('https://api.example.com', 'key-xyz');
client.get('/users'); // baseUrl and apiKey are automatically included
client.post('/orders', { item: 'laptop' });
Each client closes over its own baseUrl and apiKey. You can create multiple clients with different configurations without polluting global state.
Memoization
Cache expensive computations using closures:
function memoize(fn: (arg: number) => number) {
const cache: Record = {};
return (arg: number) => {
if (arg in cache) {
console.log(`Cache hit for ${arg}`);
return cache[arg];
}
console.log(`Computing for ${arg}`);
const result = fn(arg);
cache[arg] = result;
return result;
};
}
function expensiveCalculation(n: number): number {
return n * n * n; // imagine this is slow
}
const memoized = memoize(expensiveCalculation);
console.log(memoized(5)); // Computing for 5 → 125
console.log(memoized(5)); // Cache hit for 5 → 125
The cache object is captured in the closure and persists across calls. This is useful for debouncing API calls or avoiding redundant computations in event handlers.
Event handlers with preserved context
In a ride-hailing app I built, we needed handlers that remembered which ride they were attached to:
function setupRideHandlers(rideId: string, driverId: string) {
return {
onAccept: () => {
console.log(`Driver ${driverId} accepted ride ${rideId}`);
// Send update with rideId and driverId captured
},
onCancel: (reason: string) => {
console.log(`Ride ${rideId} cancelled: ${reason}`);
}
};
}
const rideHandlers = setupRideHandlers('ride-456', 'driver-789');
// These handlers always know which ride and driver they belong to
Instead of passing rideId and driverId around or storing them in a broader context, the closure keeps them bundled with the handler logic.
Memory awareness—closures keep captured variables alive
Closures have a cost: every captured variable stays in memory as long as the closure exists. This is usually fine, but it matters when you're creating many closures or when closures capture large data structures.
In a real-world scenario from a trading platform, I had to be careful:
function processLargeDataset(data: number[]) {
const results: number[] = [];
return () => {
// This closure captures the entire `data` array
// Even if results is small, data stays in memory
return results.length;
};
}
const getCount = processLargeDataset(new Array(10_000_000).fill(0));
// Even though we only use results, the entire data array is retained
If you don't need data after processing, clear it:
function processLargeDataset(data: number[]) {
const results: number[] = [];
// Use data to populate results...
// Explicitly remove the reference
data = null as any;
return () => {
return results.length;
};
}
This is a micro-optimization in most cases, but in memory-constrained environments or hot loops, it matters. Be intentional about what you capture.
In TypeScript, this becomes clearer because the type system makes captured variables visible. If a closure captures a large object, the type annotation makes it explicit.
Common mistakes
Mistake 1: Loop closures capturing by reference
A classic gotcha when using closures in loops:
const handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(() => console.log(i));
}
handlers[0](); // 3 (not 0!)
handlers[1](); // 3 (not 1!)
All closures capture the same i variable. By the time any handler runs, i is 3. Use let instead:
const handlers = [];
for (let i = 0; i < 3; i++) {
handlers.push(() => console.log(i));
}
handlers[0](); // 0
handlers[1](); // 1
let creates a new binding for each iteration, so each closure captures a distinct i.
Mistake 2: Expecting closure changes to affect outer scope
Closures can read and modify captured variables, but modifications are local to the closure chain:
let x = 1;
function outer() {
let x = 2;
const inner = () => {
x = 3;
};
inner();
console.log(x); // 3
}
outer();
console.log(x); // 1 (outer x is unaffected)
The inner x = 3 modifies the x in outer's scope, not the global x. This is intentional shadowing.
Mistake 3: Keeping closures alive longer than needed
If you attach event listeners that close over large objects, removing them becomes important:
const largeData = new Array(1_000_000).fill('data');
function attachListener() {
element.addEventListener('click', () => {
// This closure captures largeData
console.log(largeData.length);
});
}
attachListener();
// The event listener keeps largeData in memory forever
// Solution: remove the listener when done, or use a WeakMap
Key takeaways
- Lexical scope means variables are resolved based on where code is written, not where it's called. Every function has access to all outer scopes.
- Closures occur automatically when inner functions reference outer variables. The engine preserves those variables in memory.
- Use closures for private state (hiding data), factories (creating customized functions), memoization (caching results), and event handlers (preserving context).
- Each closure has its own captured scope chain. Creating multiple closures from the same function creates independent closures with separate captured variables.
- Memory matters: closures keep captured variables alive. Be intentional about what you capture, especially in performance-critical code or with large objects.
- Modern
letandconstprevent accidental shared state in loops. Always prefer them overvar. - TypeScript's type system makes captured variables explicit in closure signatures, helping you spot unintended captures.






Comments
Loading comments…