In the previous article, we explored the basic concepts of JavaScript functions — how to define a function, pass arguments, return values, and use arrow functions.
But understanding how to write and call a function is only the beginning.
Have you ever wondered why a function can access a variable declared outside it?
Or why a function can still access a variable even after the function that created it has already finished executing?
And how can a function be passed as an argument to another function?
These behaviors are closely related to three important JavaScript concepts: scope, closures, and higher-order functions.
In this article, we’ll explore these concepts step by step and show how they connect through practical examples.
1. Understanding Scope
Scope determines where a variable can be accessed in your code.
For example:
const name = "Dini";
function greet() {
console.log(name);
}
greet();
Enter fullscreen mode Exit fullscreen mode
The greet() function can access name even though name was not declared inside the function.
This works because name is declared in an outer scope.
Now consider this example:
function greet() {
const message = "Hello, Dini!";
console.log(message); // "Hello, Dini!"
}
greet();
console.log(message); // ReferenceError: message is not defined
Enter fullscreen mode Exit fullscreen mode
The first console.log() works because message is declared inside greet(). However, the second one causes an error because message only exists within the function’s scope.
Global Scope
└── greet()
└── message
Enter fullscreen mode Exit fullscreen mode
The variable message is accessible from inside greet(), but it is not accessible from the outside.
This is the basic idea of scope: a variable is only accessible within the scope where it is defined and the scopes nested inside it.
JavaScript has the following kinds of scopes:
- Global scope: The default scope for all code running in script mode.
- Module scope: The scope for code running in module mode.
- Function scope: The scope created with a function.
- Block scope: The scope created with a pair of curly braces (a block).
Blocks only include let and const declarations, but not var declarations.
{
var x = 10;
}
console.log(x); // 10
Enter fullscreen mode Exit fullscreen mode
{
const x = 10;
}
console.log(x); // ReferenceError: x is not defined
Enter fullscreen mode Exit fullscreen mode
2. Lexical Scope and Scope Chain
JavaScript uses lexical scoping, which means that the accessibility of a variable is determined by where the code is written, not where a function is called.
Consider this example:
const name = "Dini";
function outer() {
const greeting = "Hello";
function inner() {
console.log(greeting);
console.log(name);
}
inner();
}
outer();
Enter fullscreen mode Exit fullscreen mode
The inner() function can access both greeting and name.
Why?
Because JavaScript looks for variables through a scope chain.
You can think of the relationship like this:
inner()
↓
outer()
↓
Global Scope
Enter fullscreen mode Exit fullscreen mode
When JavaScript encounters:
console.log(greeting);
Enter fullscreen mode Exit fullscreen mode
It first looks for greeting inside inner().
If it doesn’t find it there, JavaScript looks at the outer scope, which is outer().
It finds greeting there, so the lookup stops.
For:
console.log(name);
Enter fullscreen mode Exit fullscreen mode
JavaScript doesn’t find name inside inner() or outer(), so it continues to the global scope and finds it there.
This is the scope chain.
An important thing to remember is that the relationship only works from inner scope to outer scope.
An inner function can access variables from its outer scopes:
inner → outer → global
This concept is important because it is the foundation for understanding closures.
3. What Is a Closure?
Now that we understand scope and the scope chain, let’s look at a behavior that might seem a little surprising at first.
Consider this example:
function createGreeting() {
const message = "Hello, Dini!";
return function () {
console.log(message);
};
}
const greet = createGreeting();
greet();
Enter fullscreen mode Exit fullscreen mode
The output is:
Hello, Dini!
Enter fullscreen mode Exit fullscreen mode
At first glance, you might wonder:
How can greet() still access message?
The createGreeting() function has already finished executing.
Normally, we might expect the variables inside createGreeting() to no longer be accessible from the outside.
But greet() can still access message.
This is where closures come into play.
A Function Remembers Its Surrounding Scope
When JavaScript creates the function returned by createGreeting(), that function keeps access to the variables available in the scope where it was created.
In this example:
function createGreeting() {
const message = "Hello, Dini!";
return function () {
console.log(message);
};
}
Enter fullscreen mode Exit fullscreen mode
The inner function uses message, which belongs to the outer function’s scope.
When we do this:
const greet = createGreeting();
Enter fullscreen mode Exit fullscreen mode
createGreeting() runs and returns the inner function.
We can think of the result as:
greet
↓
function () {
console.log(message);
}
↓
remembers access to
↓
message = "Hello, Dini!"
Enter fullscreen mode Exit fullscreen mode
So even though createGreeting() has finished executing, the returned function still has access to the environment where it was created.
This behavior is called a closure.
A Simple Definition
A closure is created when a function retains access to variables from its surrounding lexical scope, even after the outer function has finished executing.
In simpler words:
A closure allows a function to remember and access variables from the scope where it was created.
The important part is that the function doesn’t simply remember the value as a copy. It retains access to the variable itself.
We can see this more clearly with a counter.
Closures Can Preserve State
Consider this example:
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
Enter fullscreen mode Exit fullscreen mode
Notice that count is declared inside createCounter().
After this line:
const counter = createCounter();
Enter fullscreen mode Exit fullscreen mode
the createCounter() function has already finished executing.
Yet every time we call counter():
counter();
Enter fullscreen mode Exit fullscreen mode
the value of count is still available and continues from its previous value.
The first call returns:
1
Enter fullscreen mode Exit fullscreen mode
The second:
2
Enter fullscreen mode Exit fullscreen mode
And the third:
3
Enter fullscreen mode Exit fullscreen mode
This happens because the returned function forms a closure over the count variable.
We can visualize it like this:
createCounter()
│
├── count = 0
│
└── returns function
│
└── closes over count
↓
counter()
↓
count = 1
↓
counter()
↓
count = 2
↓
counter()
↓
count = 3
Enter fullscreen mode Exit fullscreen mode
The closure allows counter() to maintain access to count between function calls.
Closures Create Independent State
Here’s another interesting part.
What happens if we create two counters?
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counterA = createCounter();
const counterB = createCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1
console.log(counterB()); // 2
Enter fullscreen mode Exit fullscreen mode
Even though both counters are created from the same function, they maintain separate values.
counterA → count = 2
counterB → count = 2
Enter fullscreen mode Exit fullscreen mode
They don’t share the same count variable.
Each call to createCounter() creates a new lexical environment, and the returned function closes over its own environment.
This is one of the powerful aspects of closures: they can be used to create independent, persistent state.
Why Are Closures Useful?
Closures are not just an interesting JavaScript behavior. They are useful in many practical situations, such as:
- Maintaining state between function calls
- Creating function factories
- Encapsulating data
- Creating callbacks that need access to surrounding variables
- Working with asynchronous operations
For example, a function factory can use closures to create customized functions:
function createMultiplier(multiplier) {
return function (number) {
return number * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
Enter fullscreen mode Exit fullscreen mode
Here, double remembers:
multiplier = 2
Enter fullscreen mode Exit fullscreen mode
while triple remembers:
multiplier = 3
Enter fullscreen mode Exit fullscreen mode
The same createMultiplier() function can therefore create different functions with their own preserved state.
And that’s the key idea behind closures:
A function can carry access to the environment where it was created.
4. Functions as Values
So far, we’ve seen that functions can access variables from their surrounding scope and that closures can preserve access to those variables.
But there’s another important characteristic of JavaScript functions:
Functions are values.
This means a function can be treated like other values in JavaScript. We can store a function in a variable, pass it to another function, or even return it from a function.
Let’s look at each case.
Assigning a Function to a Variable
We’ve already seen this pattern before:
const greet = function () {
console.log("Hello!");
};
greet();
Enter fullscreen mode Exit fullscreen mode
Here, the function is assigned to the greet variable.
The variable doesn’t contain the result of calling the function. Instead, it contains a reference to the function itself.
That’s why we use:
greet();
Enter fullscreen mode Exit fullscreen mode
to execute it.
Compare this with:
const message = "Hello!";
console.log(message);
Enter fullscreen mode Exit fullscreen mode
In this example, message stores a string value.
Similarly:
const greet = function () {
console.log("Hello!");
};
Enter fullscreen mode Exit fullscreen mode
greet stores a function value.
We can also assign the same function to another variable:
const greet = function () {
console.log("Hello!");
};
const sayHello = greet;
sayHello();
Enter fullscreen mode Exit fullscreen mode
Both greet and sayHello refer to the same function.
This is possible because functions can be treated as values.
Passing a Function as an Argument
Functions can also be passed to other functions.
For example:
function greet(name) {
console.log(`Hello, ${name}!`);
}
function processUser(name, callback) {
callback(name);
}
processUser("Dini", greet);
Enter fullscreen mode Exit fullscreen mode
Here, greet is passed as an argument to processUser().
Notice that we pass:
greet
Enter fullscreen mode Exit fullscreen mode
not:
greet()
Enter fullscreen mode Exit fullscreen mode
These two are different.
processUser("Dini", greet);
Enter fullscreen mode Exit fullscreen mode
passes the function itself.
While:
processUser("Dini", greet());
Enter fullscreen mode Exit fullscreen mode
would execute greet() immediately and pass its return value instead.
When a function is passed to another function to be executed later, it is commonly called a callback function.
We’ll explore callbacks in more detail when we discuss higher-order functions.
Returning a Function from Another Function
Functions can also return other functions.
For example:
function createGreeting(name) {
return function () {
console.log(`Hello, ${name}!`);
};
}
const greetDini = createGreeting("Dini");
greetDini();
Enter fullscreen mode Exit fullscreen mode
Here, createGreeting() returns a function.
We then store that returned function in:
const greetDini = createGreeting("Dini");
Enter fullscreen mode Exit fullscreen mode
and execute it later:
greetDini();
Enter fullscreen mode Exit fullscreen mode
This example should look familiar because we saw something similar when discussing closures.
In fact, these two concepts are closely connected.
The returned function is not only a function value. It also forms a closure over the name variable.
That’s why this works:
const greetDini = createGreeting("Dini");
const greetNaufal = createGreeting("Naufal");
greetDini(); // Hello, Dini!
greetNaufal(); // Hello, Naufal!
Enter fullscreen mode Exit fullscreen mode
Each returned function has access to the name from its own surrounding scope.
Functions Can Be Stored, Passed, and Returned
At this point, we can summarize the three things we’ve seen:
Functions can be...
1. Stored in variables
↓
const greet = function () {};
2. Passed as arguments
↓
processUser("Dini", greet);
3. Returned from functions
↓
return function () {};
Enter fullscreen mode Exit fullscreen mode
This flexibility is one of the reasons functions are so powerful in JavaScript.
Functions that can be treated like other values are commonly described as first-class functions.
This doesn’t mean that functions are literally the same as primitive values such as strings or numbers. Rather, JavaScript allows functions to be used as values in these ways.
And this brings us to the next concept.
If a function can receive another function as an argument or return a function, what do we call a function that works with other functions?
That’s where higher-order functions come in.
5. What Is a Higher-Order Function?
In the previous section, we learned that functions can be treated as values. They can be stored in variables, passed as arguments, and returned from other functions.
This ability leads us to another important concept: higher-order functions.
What Is a Higher-Order Function?
A higher-order function is a function that does at least one of the following:
- Takes another function as an argument
- Returns a function as its result
- Or does both
Let’s start with a simple example.
function processNumber(number, operation) {
return operation(number);
}
function double(number) {
return number * 2;
}
const result = processNumber(5, double);
console.log(result); // 10
Enter fullscreen mode Exit fullscreen mode
Here, processNumber() receives double as an argument.
Because processNumber() works with another function, it is a higher-order function.
We can visualize it like this:
processNumber()
│
├── number = 5
│
└── operation = double
│
↓
double(5)
│
↓
10
Enter fullscreen mode Exit fullscreen mode
The important thing to notice is that processNumber() doesn’t need to know exactly what operation does.
It simply receives a function and executes it:
return operation(number);
Enter fullscreen mode Exit fullscreen mode
This makes the function more flexible and reusable.
Higher-Order Function vs Callback
These two concepts are closely related, but they describe different things.
Consider this example:
function processNumber(number, operation) {
return operation(number);
}
function double(number) {
return number * 2;
}
processNumber(5, double);
Enter fullscreen mode Exit fullscreen mode
Here:
-
processNumber()is the higher-order function -
double()is the callback function
Why?
Because processNumber() accepts another function as an argument.
Meanwhile, double() is the function being passed to and called by processNumber().
So we can think of the relationship like this:
Higher-order function
│
│ receives
↓
Callback function
Enter fullscreen mode Exit fullscreen mode
A callback is simply a function that is passed to another function to be used later.
A higher-order function is a function that receives or returns another function.
Higher-Order Functions Can Return Functions
Remember the example from the closure section?
function createMultiplier(multiplier) {
return function (number) {
return number * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
Enter fullscreen mode Exit fullscreen mode
createMultiplier() is also a higher-order function.
Why?
Because it returns another function.
createMultiplier(2)
↓
returns function
↓
double
Enter fullscreen mode Exit fullscreen mode
And:
createMultiplier(3)
↓
returns function
↓
triple
Enter fullscreen mode Exit fullscreen mode
This example also demonstrates how higher-order functions and closures can work together.
The returned function closes over the multiplier variable:
function createMultiplier(multiplier) {
return function (number) {
return number * multiplier;
};
}
Enter fullscreen mode Exit fullscreen mode
So double remembers multiplier = 2, while triple remembers multiplier = 3.
This is a good example of how different JavaScript concepts can work together rather than existing independently.
Why Are Higher-Order Functions Useful?
Higher-order functions allow us to write code that is more flexible, reusable, and composable.
Instead of creating a separate function for every possible operation, we can create a general function and provide the specific behavior when we need it.
For example:
function calculate(number, operation) {
return operation(number);
}
const double = calculate(10, number => number * 2);
const square = calculate(10, number => number ** 2);
console.log(double); // 20
console.log(square); // 100
Enter fullscreen mode Exit fullscreen mode
The calculate() function doesn’t need to know whether we want to double, square, or perform some other operation.
We simply provide the behavior as a function.
This pattern is extremely common in JavaScript.
In fact, you’ve probably already used higher-order functions without realizing it.
Consider:
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(number => number * 2);
Enter fullscreen mode Exit fullscreen mode
The map() method receives a function:
number => number * 2
Enter fullscreen mode Exit fullscreen mode
That function is used to determine how each item should be transformed.
map() is therefore a higher-order function.
And number => number * 2 is the callback.
The same idea appears in many other array methods, such as:
filter()
forEach()
reduce()
find()
some()
every()
Enter fullscreen mode Exit fullscreen mode
These methods allow us to provide functions that describe what should happen to each item.
We’ll explore these examples in the next section.
6. Higher-Order Functions in Array Methods
Now that we understand what higher-order functions are, let’s look at where we commonly use them in everyday JavaScript.
One of the most common places is array methods.
Methods such as map(), filter(), forEach(), and reduce() accept functions as arguments. This makes them higher-order functions.
Let’s use the same dataset throughout this section:
const users = [
{ name: "Dini", age: 25, active: true },
{ name: "Naufal", age: 30, active: false },
{ name: "Nabil", age: 28, active: true },
];
Enter fullscreen mode Exit fullscreen mode
map()
The map() method creates a new array by transforming each item in the original array.
For example, if we only need the names of our users:
const names = users.map(user => user.name);
console.log(names);
Enter fullscreen mode Exit fullscreen mode
The result is:
["Dini", "Naufal", "Nabil"]
Enter fullscreen mode Exit fullscreen mode
Here:
user => user.name
Enter fullscreen mode Exit fullscreen mode
is the callback function.
map() calls this function for each item in the array.
We can think of the process like this:
users
↓
Dini → "Dini"
Naufal → "Naufal"
Nabil → "Nabil"
↓
["Dini", "Naufal", "Nabil"]
Enter fullscreen mode Exit fullscreen mode
Because map() receives a function as an argument, map() is a higher-order function.
A useful rule of thumb is:
Use
map()when you want to transform every item into something else.
For example:
const ages = users.map(user => user.age);
Enter fullscreen mode Exit fullscreen mode
This produces:
[25, 30, 28]
Enter fullscreen mode Exit fullscreen mode
filter()
While map() transforms items, filter() selects items that satisfy a condition.
For example, we can get only active users:
const activeUsers = users.filter(user => user.active);
console.log(activeUsers);
Enter fullscreen mode Exit fullscreen mode
The result is:
[
{ name: "Dini", age: 25, active: true },
{ name: "Nabil", age: 28, active: true }
]
Enter fullscreen mode Exit fullscreen mode
The callback:
user => user.active
Enter fullscreen mode Exit fullscreen mode
returns a boolean value.
If the result is true, the item is included in the new array.
If the result is false, the item is excluded.
We can visualize it like this:
Dini → true → included
Naufal → false → excluded
Nabil → true → included
Enter fullscreen mode Exit fullscreen mode
So a useful rule is:
Use
filter()when you want to select some items from an array.
For example:
const adults = users.filter(user => user.age >= 28);
Enter fullscreen mode Exit fullscreen mode
This returns:
[
{ name: "Naufal", age: 30, active: false },
{ name: "Nabil", age: 28, active: true }
]
Enter fullscreen mode Exit fullscreen mode
forEach()
forEach() also accepts a callback function, but its purpose is different.
Instead of creating a new array, forEach() executes a function for each item.
For example:
users.forEach(user => {
console.log(user.name);
});
Enter fullscreen mode Exit fullscreen mode
The output is:
Dini
Naufal
Nabil
Enter fullscreen mode Exit fullscreen mode
The callback runs once for every item:
Dini → callback()
Naufal → callback()
Nabil → callback()
Enter fullscreen mode Exit fullscreen mode
A common use case is performing an action for each item, such as logging information or triggering another operation.
A useful rule is:
Use
forEach()when you want to perform an action for each item and don’t need a new array as the result.
One important difference is that forEach() does not return a new array like map() does.
For example:
const result = users.forEach(user => user.name);
console.log(result); // undefined
Enter fullscreen mode Exit fullscreen mode
If your goal is to create a new array from the items, map() is usually the appropriate method.
reduce()
reduce() is slightly different from the previous methods.
Instead of simply transforming or filtering items, reduce() allows us to combine multiple values into a single result.
For example, we can calculate the total age of all users:
const totalAge = users.reduce((total, user) => {
return total + user.age;
}, 0);
console.log(totalAge); // 83
Enter fullscreen mode Exit fullscreen mode
Let’s break this down.
The second argument:
0
Enter fullscreen mode Exit fullscreen mode
is the initial value of the accumulator.
The callback receives:
(total, user)
Enter fullscreen mode Exit fullscreen mode
where:
-
totalis the accumulated value -
useris the current item
The process looks like this:
Initial total = 0
0 + 25 = 25
25 + 30 = 55
55 + 28 = 83
Final result = 83
Enter fullscreen mode Exit fullscreen mode
So a useful rule is:
Use
reduce()when you want to combine an array into a single result.
That result doesn’t have to be a number.
For example, we could count active users:
const activeUserCount = users.reduce((count, user) => {
return user.active ? count + 1 : count;
}, 0);
console.log(activeUserCount); // 2
Enter fullscreen mode Exit fullscreen mode
Or even build another object:
const usersByName = users.reduce((result, user) => {
result[user.name] = user;
return result;
}, {});
console.log(usersByName);
Enter fullscreen mode Exit fullscreen mode
Output:
{
"Dini": {
"name": "Dini",
"age": 25,
"active": true
},
"Naufal": {
"name": "Naufal",
"age": 30,
"active": false
},
"Nabil": {
"name": "Nabil",
"age": 28,
"active": true
}
}
Enter fullscreen mode Exit fullscreen mode
Seeing the Pattern
At this point, we can compare the methods:
Method What it does Returnsmap()
Transforms every item
New array
filter()
Selects matching items
New array
forEach()
Performs an action for each item
undefined
reduce()
Combines items into one result
A single accumulated result
Even though these methods have different purposes, they share an important pattern:
array.method(callback);
Enter fullscreen mode Exit fullscreen mode
For example:
users.map(user => user.name);
users.filter(user => user.active);
users.forEach(user => console.log(user.name));
users.reduce((total, user) => total + user.age, 0);
Enter fullscreen mode Exit fullscreen mode
The array method controls how the array is processed, while the callback provides what should happen to each item.
That’s the power of higher-order functions.
Instead of writing the iteration logic ourselves, we can provide a function that describes the behavior we want.
For example, without filter(), we might write:
const activeUsers = [];
for (const user of users) {
if (user.active) {
activeUsers.push(user);
}
}
Enter fullscreen mode Exit fullscreen mode
With filter(), we can express the same intention more directly:
const activeUsers = users.filter(user => user.active);
Enter fullscreen mode Exit fullscreen mode
The second version focuses more on what we want — active users — rather than how to iterate through the array.
This is one reason higher-order functions are so common in modern JavaScript code.
9. Conclusion
In this article, we explored several JavaScript concepts that are closely connected to how functions work.
The important thing is to understand how these concepts connect:
- Scope: determines variable accessibility.
- Lexical Scope & Scope Chain: determines where JavaScript looks for variables.
- Closure: allows a function to retain access to its surrounding scope.
- Functions as Values: allows functions to be stored, passed, and returned.
- Higher-Order Functions: allows functions to work with other functions.
References
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions
https://developer.mozilla.org/en-US/docs/Glossary/Scope
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures