Introduction
JavaScript's behavior is governed by two fundamental concepts: scope and execution context. These mechanisms determine how variables are accessed, how functions execute, and how the language manages memory. Understanding these concepts is essential for writing efficient, bug-free JavaScript code.
The Building Analogy
Think of JavaScript execution like a large office building:
- Scopes are like different floors or rooms, each with its own set of resources
- Variables are like furniture or equipment in those rooms
- Execution contexts are like active work sessions happening in these rooms
- Call stack is like the elevator system tracking which floors you've visited
- Closures are like taking a key from one room that lets you access it from another room
Just as you can access the lobby from any floor but not necessarily someone's private office, JavaScript variables follow specific visibility rules based on where they're defined and where they're accessed.
Understanding Scope
Scope defines the accessibility of variables, functions, and objects in your code. It determines which parts of your code can "see" and access which variables.
Types of Scope in JavaScript
Global Scope
Variables declared outside any function or block have global scope and can be accessed from anywhere in your code.
// Global scope
const appName = 'MyAwesomeApp';
let version = '1.0.0';
var isProduction = false;
function displayAppInfo() {
// Can access global variables from inside functions
console.log(`${appName} v${version}`);
}
displayAppInfo(); // Outputs: MyAwesomeApp v1.0.0
Function Scope
Variables declared within a function are only accessible inside that function. Variables declared with var have function scope.
function calculateTotal(items) {
// Function scope
var total = 0;
for (var i = 0; i < items.length; i++) {
var item = items[i];
total += item.price * item.quantity;
}
return total;
}
const cartItems = [
{ name: 'Widget', price: 9.99, quantity: 2 },
{ name: 'Gadget', price: 14.99, quantity: 1 }
];
const cartTotal = calculateTotal(cartItems);
console.log(cartTotal); // 34.97
// These would cause errors - they're not accessible outside the function
// console.log(total); // ReferenceError
// console.log(i); // ReferenceError
// console.log(item); // ReferenceError
Block Scope
Variables declared with let and const inside a block (denoted by curly braces) are only accessible within that block. This includes blocks in loops, conditionals, and standalone blocks.
function processUsers(users) {
// Function scope
const validUsers = [];
for (let i = 0; i < users.length; i++) {
// Block scope (i is only accessible in this loop)
const user = users[i];
if (user.active) {
// Another block scope
let message = `Processing ${user.name}...`;
console.log(message);
validUsers.push(user);
}
}
// This would cause an error - message is block-scoped to the if statement
// console.log(message); // ReferenceError
return validUsers;
}
Lexical (Static) Scope
JavaScript uses lexical scoping, which means that functions create their scope when they are defined, not when they are executed. This means a function can access variables from its own scope, plus any variables from outer (enclosing) scopes where it was defined.
const globalVar = 'I am global';
function outerFunction() {
const outerVar = 'I am from outer function';
function innerFunction() {
const innerVar = 'I am from inner function';
// innerFunction can access:
// 1. Its own variables (innerVar)
// 2. Variables from outerFunction (outerVar)
// 3. Global variables (globalVar)
console.log(innerVar);
console.log(outerVar);
console.log(globalVar);
}
innerFunction();
// outerFunction can access:
// 1. Its own variables (outerVar)
// 2. Global variables (globalVar)
// But NOT innerFunction's variables
// console.log(innerVar); // ReferenceError
}
outerFunction();
Visualizing Scope
Scope can be visualized as a series of nested containers, where inner containers can access variables from outer containers, but not vice versa.
Variable Hoisting
Hoisting is JavaScript's behavior of moving declarations to the top of their containing scope during compilation.
Hoisting with var
Variables declared with var are hoisted with an initial value of undefined.
What You Write
console.log(hoistedVar); // undefined
var hoistedVar = 'I am hoisted';
console.log(hoistedVar); // I am hoisted
How JavaScript Sees It
var hoistedVar; // Declaration is hoisted
console.log(hoistedVar); // undefined
hoistedVar = 'I am hoisted'; // Assignment stays in place
console.log(hoistedVar); // I am hoisted
Function Declaration Hoisting
Function declarations are hoisted completely, allowing them to be called before they are defined.
// This works because the function declaration is hoisted
sayHello(); // Outputs: Hello, World!
function sayHello() {
console.log('Hello, World!');
}
Hoisting with let and const
Variables declared with let and const are hoisted but not initialized. Accessing them before declaration results in a ReferenceError (the "Temporal Dead Zone").
// This causes a ReferenceError
// console.log(nonHoistedVar); // ReferenceError
let nonHoistedVar = 'I am not hoisted';
console.log(nonHoistedVar); // I am not hoisted
Execution Context
While scope is about variable accessibility, execution context is about the environment in which JavaScript code is executed. It manages the entire process of code evaluation and execution.
Types of Execution Contexts
Global Execution Context
This is the default context created when a JavaScript script starts running. It performs two primary tasks:
- Creates a global object (window in browsers, global in Node.js)
- Sets the value of
thisto reference the global object
// In browser's global execution context
console.log(this === window); // true
// Global variables become properties of the global object
var globalVar = 'global';
console.log(window.globalVar); // 'global'
Function Execution Context
Created whenever a function is called. Each function call creates a new execution context.
function greet(name) {
// A new function execution context is created here
const greeting = 'Hello';
return `${greeting}, ${name}!`;
}
console.log(greet('Alice')); // Hello, Alice!
console.log(greet('Bob')); // Hello, Bob!
Eval Execution Context
Created when code is executed inside an eval function. This is rarely used in modern JavaScript and generally discouraged for security reasons.
Phases of Execution Context
Each execution context goes through two main phases:
Creation Phase
During this phase, JavaScript:
- Creates the Variable Environment (storing variables and function declarations)
- Creates the Scope Chain (link to outer environments)
- Sets the value of
this
Execution Phase
During this phase, JavaScript executes the code line by line, assigning values to variables and executing function calls.
The Call Stack
The call stack is a mechanism JavaScript uses to keep track of execution contexts. It works like a stack of tasks:
- When a function is called, a new execution context is created and pushed onto the stack
- When a function completes, its execution context is popped off the stack
- The context on top of the stack is the one currently running
Call Stack Example
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const squared = square(n);
console.log(squared);
}
// Call stack operations when we execute:
printSquare(4); // Output: 16
Stack Overflow
If the call stack exceeds its maximum size (e.g., due to infinite recursion), a "stack overflow" error occurs:
// This will cause a stack overflow
function causeStackOverflow() {
// Recursive call without a proper base case
causeStackOverflow();
}
// Don't actually run this!
// causeStackOverflow();
// Error: Maximum call stack size exceeded
The 'this' Keyword
The this keyword is one of the most powerful yet confusing aspects of JavaScript. It refers to the context in which a function is executed and can have different values depending on how the function is called.
How 'this' Is Determined
The value of this is determined by how a function is called, not where it's defined. This is crucial to understand.
Rule 1: Default Binding (Regular Function Call)
When a function is called on its own, this refers to the global object (or undefined in strict mode).
function showThis() {
console.log(this);
}
// In non-strict mode
showThis(); // Window (browser) or global (Node.js)
// In strict mode
'use strict';
function strictShowThis() {
console.log(this);
}
strictShowThis(); // undefined
Rule 2: Implicit Binding (Method Call)
When a function is called as a method of an object, this refers to the object the method is called on.
const user = {
name: 'Alice',
greet: function() {
console.log(`Hello, my name is ${this.name}`);
}
};
user.greet(); // Output: Hello, my name is Alice
// But be careful with references
const greetFunction = user.greet;
greetFunction(); // Output: Hello, my name is undefined
// (because it's now a regular function call)
Rule 3: Explicit Binding (call, apply, bind)
You can explicitly set the value of this using the call(), apply(), or bind() methods.
function introduce(greeting, punctuation) {
console.log(`${greeting}, my name is ${this.name}${punctuation}`);
}
const person1 = { name: 'Alice' };
const person2 = { name: 'Bob' };
// Using call (arguments listed individually)
introduce.call(person1, 'Hello', '!');
// Output: Hello, my name is Alice!
// Using apply (arguments as an array)
introduce.apply(person2, ['Hi', '...']);
// Output: Hi, my name is Bob...
// Using bind (creates a new function with 'this' bound)
const aliceIntroduce = introduce.bind(person1);
aliceIntroduce('Hey', '.');
// Output: Hey, my name is Alice.
Rule 4: Constructor Binding (new keyword)
When a function is called with the new keyword, this refers to the newly created instance.
function User(name) {
this.name = name;
this.isActive = true;
this.greeting = function() {
return `Hi, I'm ${this.name}`;
};
}
const alice = new User('Alice');
console.log(alice.greeting()); // Output: Hi, I'm Alice
Rule 5: Arrow Functions
Arrow functions don't have their own this binding. They inherit this from the enclosing lexical context.
const team = {
members: ['Alice', 'Bob', 'Charlie'],
teamName: 'Awesome Team',
// Regular function has its own 'this'
showTeamWithRegular: function() {
console.log(`Team: ${this.teamName}`);
// 'this' inside the callback refers to global object
this.members.forEach(function(member) {
console.log(`${member} is in ${this.teamName}`);
// Will output: Alice is in undefined
});
},
// Arrow function inherits 'this' from parent scope
showTeamWithArrow: function() {
console.log(`Team: ${this.teamName}`);
// 'this' inside the arrow function refers to the team object
this.members.forEach(member => {
console.log(`${member} is in ${this.teamName}`);
// Will output: Alice is in Awesome Team
});
}
};
team.showTeamWithRegular();
team.showTeamWithArrow();
Common 'this' Issues and Solutions
The dynamic nature of this can lead to confusion and bugs. Here are common pitfalls and solutions:
Issue: Losing 'this' in Callbacks
// Problem
const user = {
name: 'Alice',
loadProfile: function() {
// Simulating async operation
setTimeout(function() {
console.log(`Loading profile for ${this.name}`);
// Output: Loading profile for undefined
// ('this' refers to the window object in the callback)
}, 1000);
}
};
user.loadProfile();
Solutions:
Solution 1: Use Arrow Function
const user = {
name: 'Alice',
loadProfile: function() {
// Arrow function inherits 'this'
setTimeout(() => {
console.log(`Loading profile for ${this.name}`);
// Output: Loading profile for Alice
}, 1000);
}
};
Solution 2: Store 'this' in a Variable
const user = {
name: 'Alice',
loadProfile: function() {
// Store 'this' in a variable for use in the callback
const self = this;
setTimeout(function() {
console.log(`Loading profile for ${self.name}`);
// Output: Loading profile for Alice
}, 1000);
}
};
Solution 3: Use bind()
const user = {
name: 'Alice',
loadProfile: function() {
// Bind 'this' to the callback function
setTimeout(function() {
console.log(`Loading profile for ${this.name}`);
// Output: Loading profile for Alice
}.bind(this), 1000);
}
};
Issue: 'this' in Event Handlers
In browser environments, event handlers have this bound to the element that triggered the event:
// HTML: <button id="myButton">Click Me</button>
const button = document.getElementById('myButton');
const user = {
name: 'Alice',
handleClick: function() {
console.log(`Button clicked by ${this.name}`);
}
};
// Problem: 'this' will refer to the button, not the user object
button.addEventListener('click', user.handleClick);
// Output when clicked: Button clicked by undefined
// Solution: Bind the method to maintain the correct 'this'
button.addEventListener('click', user.handleClick.bind(user));
// Output when clicked: Button clicked by Alice
Understanding Closures
Closures are one of the most powerful features in JavaScript. A closure is formed when a function retains access to variables from its outer (enclosing) scope, even after the outer function has completed execution.
What Is a Closure?
A closure is created whenever a function is defined inside another function, allowing the inner function to access the outer function's variables and parameters even after the outer function has finished executing.
to Outer Function Variables] A --> B B --> C C --> D
Basic Closure Example
function createGreeting(greeting) {
// The inner function is defined inside createGreeting
return function(name) {
// It has access to the 'greeting' parameter
return `${greeting}, ${name}!`;
};
}
// Create specific greeting functions
const sayHello = createGreeting('Hello');
const sayHi = createGreeting('Hi');
const sayGoodMorning = createGreeting('Good morning');
// Use the greeting functions
console.log(sayHello('Alice')); // Hello, Alice!
console.log(sayHi('Bob')); // Hi, Bob!
console.log(sayGoodMorning('Charlie')); // Good morning, Charlie!
// Each function "remembers" its specific greeting
// even though createGreeting has completed execution
Visualizing Closures
A closure consists of:
- The function itself
- The environment in which the function was created (including variables and parameters)
Practical Applications of Closures
Data Privacy and Encapsulation
Closures allow for private variables that can't be accessed directly from outside:
function createCounter() {
// Private variable - not accessible from outside
let count = 0;
return {
increment: function() {
count += 1;
return count;
},
decrement: function() {
count -= 1;
return count;
},
getValue: function() {
return count;
},
reset: function() {
count = 0;
return count;
}
};
}
const counter = createCounter();
console.log(counter.getValue()); // 0
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.reset()); // 0
// The 'count' variable cannot be accessed directly
console.log(counter.count); // undefined
Function Factories
Create specialized functions with pre-configured behavior:
function createMultiplier(factor) {
return function(number) {
return number * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const quadruple = createMultiplier(4);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(quadruple(5)); // 20
Maintaining State in Async Operations
Closures are particularly useful for preserving values in asynchronous code:
function fetchUserData(userId) {
// The userId is captured in the closure
return function() {
console.log(`Fetching data for user ${userId}...`);
// In a real app, this would be an API call
return fetch(`/api/users/${userId}`)
.then(response => response.json());
};
}
// Create fetcher functions for specific users
const fetchUser123 = fetchUserData(123);
const fetchUser456 = fetchUserData(456);
// Later in the code, we can use these functions
// and they'll remember which user ID to fetch
document.getElementById('user123Button').addEventListener('click', () => {
fetchUser123().then(data => displayUserData(data));
});
document.getElementById('user456Button').addEventListener('click', () => {
fetchUser456().then(data => displayUserData(data));
});
Memoization (Caching Results)
Use closures to cache function results for better performance:
function createMemoizedFunction(fn) {
// Cache stored in closure
const cache = {};
return function(...args) {
// Create a key from the arguments
const key = JSON.stringify(args);
// If we have a cached result, return it
if (key in cache) {
console.log('Returning cached result for', args);
return cache[key];
}
// Otherwise, calculate the result and cache it
const result = fn(...args);
cache[key] = result;
console.log('Calculating new result for', args);
return result;
};
}
// Example: Memoized expensive calculation
function expensiveCalculation(n) {
console.log('Performing expensive calculation...');
// Simulate expensive operation
let result = 0;
for (let i = 0; i < n * 1000000; i++) {
result += Math.random();
}
return result;
}
const memoizedCalculation = createMemoizedFunction(expensiveCalculation);
// First call: will perform the calculation
console.time('First call');
memoizedCalculation(2);
console.timeEnd('First call');
// Second call with same argument: will use cached result
console.time('Second call');
memoizedCalculation(2);
console.timeEnd('Second call');
// Call with different argument: will perform calculation again
console.time('Different argument');
memoizedCalculation(3);
console.timeEnd('Different argument');
Module Pattern
Create modules with private and public parts:
const userModule = (function() {
// Private variables and functions
let currentUser = null;
const privateUserData = {};
function validateUser(user) {
return user && user.id && user.name;
}
// Public API
return {
login: function(user) {
if (validateUser(user)) {
currentUser = user;
privateUserData[user.id] = { loginCount: 1, lastLogin: new Date() };
return true;
}
return false;
},
logout: function() {
currentUser = null;
return true;
},
getCurrentUser: function() {
return currentUser;
},
getUserStats: function(userId) {
// Only return non-sensitive stats
const userData = privateUserData[userId];
if (userData) {
return {
loginCount: userData.loginCount,
lastLogin: userData.lastLogin
};
}
return null;
}
};
})();
// Usage
userModule.login({ id: 123, name: 'Alice' });
console.log(userModule.getCurrentUser()); // { id: 123, name: 'Alice' }
console.log(userModule.getUserStats(123)); // { loginCount: 1, lastLogin: Date }
// Private parts not accessible
console.log(userModule.currentUser); // undefined
console.log(userModule.privateUserData); // undefined
console.log(userModule.validateUser); // undefined
Closure Gotchas and Solutions
Gotcha: Loops and Closures
A classic issue occurs when creating closures in loops:
// Problem: All closures share the same environment
function createButtons() {
const buttons = [];
for (var i = 0; i < 5; i++) {
// This closure captures the variable i, not its value
buttons.push(function() {
console.log(`Button ${i} clicked`);
});
}
return buttons;
}
const buttons = createButtons();
// When we call these functions, they all use the final value of i (5)
buttons[0](); // Expected: "Button 0 clicked", Actual: "Button 5 clicked"
buttons[1](); // Expected: "Button 1 clicked", Actual: "Button 5 clicked"
buttons[2](); // Expected: "Button 2 clicked", Actual: "Button 5 clicked"
Solutions:
Solution 1: Use let Instead of var
function createButtons() {
const buttons = [];
// let creates a new binding for each iteration
for (let i = 0; i < 5; i++) {
buttons.push(function() {
console.log(`Button ${i} clicked`);
});
}
return buttons;
}
const buttons = createButtons();
buttons[0](); // "Button 0 clicked"
buttons[1](); // "Button 1 clicked"
Solution 2: Use an IIFE (Immediately Invoked Function Expression)
function createButtons() {
const buttons = [];
for (var i = 0; i < 5; i++) {
// IIFE creates a new scope for each iteration
(function(index) {
buttons.push(function() {
console.log(`Button ${index} clicked`);
});
})(i);
}
return buttons;
}
const buttons = createButtons();
buttons[0](); // "Button 0 clicked"
buttons[1](); // "Button 1 clicked"
Gotcha: Memory Leaks
Closures can cause memory leaks if not managed properly:
// Potential memory leak
function setupLongLivedApplication() {
// Large data structure
const largeData = new Array(1000000).fill('potentially large data');
// Event listener that references the large data
document.getElementById('myButton').addEventListener('click', function() {
// This closure holds a reference to largeData
console.log('Processing data of size:', largeData.length);
});
}
Solutions:
Solution: Clean Up References When No Longer Needed
function setupLongLivedApplication() {
// Large data structure
const largeData = new Array(1000000).fill('potentially large data');
// Event handler function stored in a variable so we can remove it later
const handleClick = function() {
console.log('Processing data of size:', largeData.length);
};
// Add the event listener
document.getElementById('myButton').addEventListener('click', handleClick);
// Return a cleanup function
return function cleanup() {
// Remove the event listener
document.getElementById('myButton').removeEventListener('click', handleClick);
// Clear any references to large data
// (in a real app, set any large variables to null)
};
}
const cleanup = setupLongLivedApplication();
// Later, when no longer needed
cleanup();
The Scope Chain
The scope chain is the hierarchical connection between nested scopes that determines variable access. When JavaScript looks for a variable, it starts in the current scope and moves up the chain until it finds the variable or reaches the global scope.
Visualizing the Scope Chain
Scope Chain Example
// Global scope
const global = 'I am global';
function outer() {
// outer function scope
const outerVar = 'I am from outer';
function middle() {
// middle function scope
const middleVar = 'I am from middle';
function inner() {
// inner function scope
const innerVar = 'I am from inner';
// Variable lookup demonstration
console.log(innerVar); // Found in inner scope
console.log(middleVar); // Found in middle scope
console.log(outerVar); // Found in outer scope
console.log(global); // Found in global scope
// This would cause a ReferenceError
// console.log(nonExistent);
}
inner();
}
middle();
}
outer();
Variable Shadowing
Shadowing occurs when a variable in an inner scope has the same name as a variable in an outer scope. The inner variable "shadows" the outer one.
const value = 'global';
function outer() {
const value = 'outer';
function inner() {
const value = 'inner';
console.log('inner value:', value); // 'inner'
}
console.log('outer value:', value); // 'outer'
inner();
}
console.log('global value:', value); // 'global'
outer();
Scope Chain and Closures
Closures work because of the scope chain. A function "remembers" its scope chain, including all outer variables:
function createMultiplier(factor) {
// 'factor' is in the outer function's scope
return function(number) {
// This function has access to 'factor' via the scope chain
return number * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// Each returned function has its own closure with its own 'factor' value
Lexical vs. Dynamic Scoping
Understanding the difference between lexical and dynamic scoping helps clarify how JavaScript's scope and this binding work.
Lexical (Static) Scoping
JavaScript uses lexical scoping for variables. This means the scope is determined by where a function is defined, not where it's called.
const name = 'Global';
function lexicalExample() {
const name = 'Lexical';
function innerFunction() {
// Uses the 'name' from the scope where innerFunction was defined
console.log(name);
}
return innerFunction;
}
const inner = lexicalExample();
inner(); // Output: "Lexical"
Dynamic Scoping
JavaScript uses dynamic scoping for the this keyword. This means this is determined by how a function is called, not where it's defined.
function dynamicExample() {
console.log(this.name);
}
const obj1 = {
name: 'Object 1',
method: dynamicExample
};
const obj2 = {
name: 'Object 2',
method: dynamicExample
};
// Same function, different 'this' values based on how it's called
obj1.method(); // Output: "Object 1"
obj2.method(); // Output: "Object 2"
Side-by-Side Comparison
| Lexical Scoping (Variables) | Dynamic Scoping (this) |
|---|---|
| Determined by function definition location | Determined by function call style |
| Fixed at function creation time | Can change between calls |
| Predictable by reading the code structure | Requires understanding how the function is invoked |
| Affects regular variables | Affects only the this binding |
| Not affected by bind(), call(), apply() | Can be explicitly set with bind(), call(), apply() |
Arrow Functions: Resolving the Confusion
Arrow functions bring lexical behavior to this, making it behave like regular variables:
const obj = {
name: 'My Object',
// Regular function uses dynamic 'this'
regularMethod: function() {
console.log('Regular function this:', this.name);
setTimeout(function() {
// 'this' here is NOT the obj, it's the global object or undefined
console.log('Regular function in callback this:', this.name);
}, 100);
},
// Arrow function uses lexical 'this'
arrowMethod: function() {
console.log('Container function this:', this.name);
setTimeout(() => {
// 'this' here IS the obj, it's inherited from the lexical scope
console.log('Arrow function in callback this:', this.name);
}, 100);
}
};
obj.regularMethod();
// Output:
// Regular function this: My Object
// Regular function in callback this: undefined (or window.name in non-strict mode)
obj.arrowMethod();
// Output:
// Container function this: My Object
// Arrow function in callback this: My Object
Summary and Key Takeaways
In this lecture, we've explored the fundamental concepts of scope, execution context, this binding, and closures in JavaScript. Here are the key takeaways:
Scope
- Scope determines the accessibility of variables from different parts of your code
- JavaScript has global scope, function scope, and block scope (with
letandconst) - Lexical scoping means functions can access variables from their outer scopes
- The scope chain is a hierarchical connection between scopes that governs variable access
Execution Context
- Execution context is the environment in which JavaScript code runs
- It includes variable environment, scope chain, and
thisbinding - Each function call creates a new execution context
- The call stack tracks the sequence of execution contexts
The 'this' Keyword
thisis determined by how a function is called, not where it's defined- It can be bound explicitly with
call(),apply(), andbind() - Arrow functions inherit
thisfrom their surrounding lexical context - Different calling patterns result in different
thisvalues
Closures
- Closures allow functions to retain access to variables from their parent scopes
- They can be used for data privacy, function factories, and maintaining state
- Closures are the foundation for many JavaScript patterns and architecture
- Be mindful of potential memory leaks when using closures
Practical Applications
- Module Pattern: Organize code with private and public parts
- Function Factories: Create specialized functions with preset behavior
- Data Encapsulation: Hide implementation details while exposing an API
- Callbacks and Higher-Order Functions: Pass and return functions
- Event Handling: Manage event context and data
- Asynchronous Programming: Maintain context across async operations
Understanding these concepts is essential for writing effective, maintainable JavaScript code and mastering advanced techniques and frameworks.
Further Learning
Practice Activities
- Scope Chain Explorer: Build a nested set of functions and trace how the scope chain allows access to variables at different levels.
-
Context Manipulator: Create a function that demonstrates all the different ways to control the
thisbinding. - Private Data Structures: Implement a data structure (like a stack or queue) using closures to encapsulate the internal state.
- Currying Implementation: Create a utility for converting regular functions into curried functions using closures.
- Pub/Sub System: Build a publish/subscribe event system using closures to manage event handlers.