JavaScript Callbacks and Higher-Order Functions Explained
Learn what callbacks and higher-order functions are, how map, filter and sort use them, and how function composition builds big logic from small functions.
What you will learn
In the last lessons you learned that functions are values: you can store them in variables and pass them around. That one idea unlocks a powerful style of programming. In this lesson you will learn what a callback is, what a higher-order function is, how built-in tools like map, filter and sort use callbacks, and how function composition lets you build big features by snapping small functions together.
What is a callback?
A callback is a function that you pass into another function, so that the other function can call it back later.
Think of a restaurant. When the tables are full, you give the waiter your phone number. You do not stand and stare at the door. When your table is ready, the restaurant calls you back. Your phone number is the callback: “Do this when you are ready.”
function greet(name, callback) {
console.log("Hello, " + name + "!");
callback();
}
function afterGreeting() {
console.log("Nice to meet you.");
}
greet("Riya", afterGreeting);
Output:
Hello, Riya!
Nice to meet you.
afterGreeting is the callback. We pass it without brackets, because we are handing over the function itself, not running it. The function greet decides when to call it.
Callbacks can receive arguments
function repeat(times, action) {
for (let i = 1; i <= times; i++) {
action(i); // the callback gets the round number
}
}
repeat(3, (round) => {
console.log("Round " + round);
});
Output:
Round 1
Round 2
Round 3
The repeat function knows how to loop, and the callback decides what to do each time. This split makes repeat reusable for any job.
Named or inline
You can pass a function you defined earlier, or write one directly where it is needed:
const shout = (text) => console.log(text.toUpperCase() + "!");
function processText(text, handler) {
handler(text);
}
processText("hello", shout); // named callback
processText("hello", (t) => console.log(t + "?")); // inline (anonymous) callback
Passing vs calling
const shout = (text) => console.log(text.toUpperCase() + "!");
function processText(text, handler) {
handler(text);
}
processText("hello", shout); // correct: passes the function
// processText("hello", shout()); // WRONG: runs shout() now and passes its result
No brackets means “here is the function”. Brackets mean “run it right now”.
Synchronous and asynchronous callbacks
Callbacks come in two flavours.
Synchronous callbacks run immediately, during the function call. Everything above, and array methods like map and forEach, work like this.
Asynchronous callbacks run later, when something happens or when some time has passed:
console.log("1. Start");
setTimeout(() => {
console.log("3. Runs after 1 second");
}, 1000);
console.log("2. End");
Output:
1. Start
2. End
3. Runs after 1 second
JavaScript does not wait. It carries on with the next line, and calls your callback when the timer finishes. The same idea is used for clicks and for data that arrives from a server. You will study this properly in the Asynchronous JavaScript section.
A simulated data load
function loadUser(id, callback) {
console.log("Loading user " + id + "...");
setTimeout(() => {
const user = { id: id, name: "Riya" };
callback(user); // call back with the data when it is "ready"
}, 500);
}
loadUser(1, (user) => {
console.log("Loaded: " + user.name);
});
Output:
Loading user 1...
Loaded: Riya
(The second line appears half a second later.)
Higher-order functions
A higher-order function is a function that does at least one of these two things:
- Takes a function as an argument (it accepts a callback), or
- Returns a function.
Every function that accepts a callback is a higher-order function. So greet(name, callback) and repeat(times, action) above are higher-order functions, and so are setTimeout and addEventListener.
// Type 1: takes a function
function applyTwice(fn, value) {
return fn(fn(value));
}
console.log(applyTwice((n) => n + 3, 10)); // 16
console.log(applyTwice((text) => text + "!", "Hi")); // "Hi!!"
// Type 2: returns a function
function createMultiplier(factor) {
return (number) => number * factor;
}
const double = createMultiplier(2);
console.log(double(8)); // 16
Why is it useful? It lets you separate what stays the same (the loop, the timing, the checking) from what changes (the action). You write the common part once.
Higher-order functions you will use every day
Arrays have several built-in higher-order methods. Each one takes a callback. (The arrays lessons cover them in detail, but you need the basic idea now.)
| Method | Callback job | Returns |
|---|---|---|
forEach(fn) |
Do something with each item | Nothing (undefined) |
map(fn) |
Transform each item | A new array of the results |
filter(fn) |
Say yes/no for each item | A new array of items that got “yes” |
find(fn) |
Say yes/no, stops at the first yes | The first matching item |
reduce(fn, start) |
Combine items into one value | A single value |
sort(fn) |
Compare two items | The same array, sorted |
The callback usually receives the current item. It can also receive the index and the whole array: (item, index, array).
const numbers = [1, 2, 3, 4, 5];
numbers.forEach((n) => console.log("Item:", n));
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4]
const firstBig = numbers.find((n) => n > 3);
console.log(firstBig); // 4
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum); // 15
With the index:
const names = ["Riya", "Karan", "Neha"];
const numbered = names.map((name, index) => `${index + 1}. ${name}`);
console.log(numbered); // ["1. Riya", "2. Karan", "3. Neha"]
Sorting with a comparator
sort() without a callback sorts items as text, which breaks numbers:
console.log([10, 9, 1].sort()); // [1, 10, 9] (wrong for numbers)
console.log([10, 9, 1].sort((a, b) => a - b)); // [1, 9, 10] (correct)
The callback compares two items a and b. If it returns a negative number, a goes first. If it returns a positive number, b goes first. Use b - a for descending order.
Build your own map and filter
The best way to understand a higher-order function is to build one. Here is how map and filter work inside:
function myMap(array, transform) {
const result = [];
for (const item of array) {
result.push(transform(item)); // call the callback for each item
}
return result;
}
function myFilter(array, test) {
const result = [];
for (const item of array) {
if (test(item)) { // the callback answers true or false
result.push(item);
}
}
return result;
}
console.log(myMap([1, 2, 3], (n) => n * 10)); // [10, 20, 30]
console.log(myFilter([1, 2, 3, 4], (n) => n > 2)); // [3, 4]
There is no magic. They are loops that call your function for each item. (A callback that answers true or false is called a predicate.)
Function composition
Function composition means combining small functions so that the output of one becomes the input of the next.
In maths it looks like f(g(x)). In code:
const trim = (text) => text.trim();
const lower = (text) => text.toLowerCase();
const addDashes = (text) => text.replaceAll(" ", "-");
const result = addDashes(lower(trim(" Hello World ")));
console.log(result); // "hello-world"
It works, but you must read it from the inside out, which is awkward. Let us build a helper.
A compose for two functions
const compose = (f, g) => (x) => f(g(x)); // runs g first, then f
const shout = (text) => text.toUpperCase();
const exclaim = (text) => text + "!";
const loudGreeting = compose(exclaim, shout);
console.log(loudGreeting("hello")); // "HELLO!"
compose(f, g) returns a new function. Calling it runs g first, then passes the result to f. We built a new function from two small ones, without writing any new logic.
pipe: read left to right
Many people find it more natural to list the steps in the order they happen. That helper is usually called pipe:
const trim = (text) => text.trim();
const lower = (text) => text.toLowerCase();
const addDashes = (text) => text.replaceAll(" ", "-");
const pipe = (...functions) => (value) =>
functions.reduce((result, fn) => fn(result), value);
const makeSlug = pipe(trim, lower, addDashes);
console.log(makeSlug(" JavaScript Is Fun ")); // "javascript-is-fun"
Read pipe(trim, lower, addDashes) as: “first trim, then lower-case, then add dashes.”
A note on the syntax: the three dots in (...functions) collect all the arguments into an array. This is called a rest parameter, and you will learn it in the destructuring, spread and rest lesson. For now, take it as “accept any number of functions”.
Composition in a price pipeline
const pipe = (...functions) => (value) =>
functions.reduce((result, fn) => fn(result), value);
const applyDiscount = (price) => price * 0.9; // 10% off
const addGST = (price) => price * 1.18; // 18% tax
const roundTwo = (price) => Math.round(price * 100) / 100;
const formatPrice = (price) => "₹" + price.toFixed(2);
const finalPrice = pipe(applyDiscount, addGST, roundTwo, formatPrice);
console.log(finalPrice(1000)); // "₹1062.00"
console.log(finalPrice(250)); // "₹265.50"
Each step is tiny, pure and easy to test. Need a new rule, such as free shipping? Add one function to the pipeline. This is the real power of composition.
Real-life use cases
1. Filtering products with a custom condition
const products = [
{ name: "Notebook", price: 80 },
{ name: "Headphones", price: 1500 },
{ name: "Backpack", price: 900 },
{ name: "Pen", price: 20 }
];
function filterBy(items, condition) {
const result = [];
for (const item of items) {
if (condition(item)) {
result.push(item);
}
}
return result;
}
const expensive = filterBy(products, (p) => p.price >= 500);
console.log(expensive.map((p) => p.name)); // ["Headphones", "Backpack"]
You can reuse filterBy with any rule: (p) => p.price < 100, (p) => p.name.startsWith("B") and so on.
2. Sorting objects
const products = [
{ name: "Notebook", price: 80 },
{ name: "Headphones", price: 1500 },
{ name: "Backpack", price: 900 },
{ name: "Pen", price: 20 }
];
const byPrice = [...products].sort((a, b) => a.price - b.price);
console.log(byPrice.map((p) => p.name)); // ["Pen", "Notebook", "Backpack", "Headphones"]
[...products] makes a copy first, because sort changes the array it is called on.
3. Total cart value
const products = [
{ name: "Notebook", price: 80 },
{ name: "Headphones", price: 1500 },
{ name: "Backpack", price: 900 },
{ name: "Pen", price: 20 }
];
const cartTotal = products
.filter((p) => p.price >= 500)
.map((p) => p.price * 0.9)
.reduce((sum, price) => sum + price, 0);
console.log(cartTotal); // 2160
Keep the items of ₹500 or more, take 10% off, and add them up. Read it line by line like a recipe.
4. Reusable validation rules
const isLongEnough = (text) => text.length >= 8;
const hasNumber = (text) => /\d/.test(text);
const hasNoSpaces = (text) => !text.includes(" ");
function validate(text, rules) {
for (const rule of rules) {
if (!rule(text)) {
return false;
}
}
return true;
}
const passwordRules = [isLongEnough, hasNumber, hasNoSpaces];
console.log(validate("hello123", passwordRules)); // true
console.log(validate("hello", passwordRules)); // false
The rules are just functions in an array. Adding a new rule means adding one function to the list.
5. Event handlers
const button = document.getElementById("btn");
button.addEventListener("click", () => {
console.log("Button clicked!");
});
addEventListener is a higher-order function. You hand it a callback, and the browser calls it every time the button is clicked.
6. A wrapper that adds logging
A function that takes a function and returns an improved version is a very common pattern:
function withLogging(fn) {
return function (...args) {
console.log("Calling with:", args);
const result = fn(...args);
console.log("Result:", result);
return result;
};
}
const add = (a, b) => a + b;
const loggedAdd = withLogging(add);
loggedAdd(2, 3);
Output:
Calling with: [2, 3]
Result: 5
add was not changed at all, but loggedAdd now tells you what it is doing.
7. Run an action only for some items
function doIf(items, condition, action) {
for (const item of items) {
if (condition(item)) {
action(item);
}
}
}
doIf(
[4, 9, 12, 7],
(n) => n > 6,
(n) => console.log("Big number:", n)
);
Output:
Big number: 9
Big number: 12
Big number: 7
Callback hell (a small warning)
When asynchronous steps depend on each other, callbacks get nested deeper and deeper:
loadUser(1, (user) => {
loadOrders(user.id, (orders) => {
loadItems(orders[0].id, (items) => {
showItems(items); // the "pyramid of doom"
});
});
});
This is hard to read and to handle errors in. It is called callback hell. JavaScript solved it with Promises and async/await, which you will meet in the Asynchronous JavaScript section. For now, just know that callbacks are the foundation they are built on.
Common mistakes
-
Calling the callback instead of passing it.
setTimeout(greet(), 1000)andbutton.addEventListener("click", handle())run the function immediately. Passgreetor() => greet(). -
Forgetting to
returninmap. With braces,numbers.map((n) => { n * 2 })gives[undefined, undefined, ...]. Addreturn, or remove the braces. -
Using
mapwhen you do not need the new array. UseforEachfor side effects, andmapto build a new array. -
Sorting numbers without a comparator.
[10, 9, 1].sort()gives[1, 10, 9]. -
Forgetting that
sortchanges the original array. Copy it first with[...array]. -
Passing a function that expects different arguments. The classic example:
console.log(["1", "2", "3"].map(parseInt)); // [1, NaN, NaN] (surprise!)mappasses(item, index), soparseInt("2", 1)treats1as the number base and fails. Use["1", "2", "3"].map(Number)ormap((s) => parseInt(s, 10)). -
Using a regular function for a callback that needs
this. Arrow functions keep the outerthis. -
Nesting callbacks deeply. Give each step a name, or use Promises later.
-
Writing a callback that never calls back. If your function promises to call
callback, make sure every path does it.
Practice
-
Write
function runTwice(action)that callsaction()two times. Test it with a function that prints your name. -
Write
function forEachLetter(text, callback)that callscallback(letter, index)for every letter of a string. -
Use
.map()to turn[1, 2, 3, 4]into[1, 4, 9, 16]. -
Use
.filter()to keep only the words with more than 4 letters from["tree", "apple", "sun", "banana"]. -
Use
.reduce()to find the product of[2, 3, 4]. -
Sort
[40, 5, 100, 25]from smallest to largest, then from largest to smallest. -
Write your own
myForEach(array, callback)without using the built-inforEach. -
Predict the output before running it, then explain why:
console.log(["10", "10", "10"].map(parseInt)); -
Using
pipe, buildcleanNamethat trims the text, makes it lower case, and then capitalises the first letter. -
Challenge: write
filterBy(items, condition)andmapBy(items, transform), then use them together to get the names of all products cheaper than ₹100 from a product list.
Recap
- A callback is a function passed into another function, to be called by it later. Pass it without brackets.
- Callbacks can be synchronous (run right away, like
map) or asynchronous (run later, likesetTimeoutand click handlers). - A higher-order function takes a function as an argument, or returns a function (or both).
- Built-in higher-order array methods:
forEach,map,filter,find,reduceandsort. Always givesorta comparator for numbers. - Building
mapandfilteryourself shows that they are just loops that call your function. - Function composition combines small functions:
compose(f, g)(x)isf(g(x)), andpipelists the steps in the order they run. - Keep callbacks small and named when they get complicated. Deeply nested callbacks lead to “callback hell”, which Promises and async/await solve.
- Next you will learn closures: how a function remembers the variables around it.