JavaScriptIntermediate

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.

All JavaScript lessons

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:

  1. Takes a function as an argument (it accepts a callback), or
  2. 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) and button.addEventListener("click", handle()) run the function immediately. Pass greet or () => greet().

  • Forgetting to return in map. With braces, numbers.map((n) => { n * 2 }) gives [undefined, undefined, ...]. Add return, or remove the braces.

  • Using map when you do not need the new array. Use forEach for side effects, and map to build a new array.

  • Sorting numbers without a comparator. [10, 9, 1].sort() gives [1, 10, 9].

  • Forgetting that sort changes 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!)

    map passes (item, index), so parseInt("2", 1) treats 1 as the number base and fails. Use ["1", "2", "3"].map(Number) or map((s) => parseInt(s, 10)).

  • Using a regular function for a callback that needs this. Arrow functions keep the outer this.

  • 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

  1. Write function runTwice(action) that calls action() two times. Test it with a function that prints your name.

  2. Write function forEachLetter(text, callback) that calls callback(letter, index) for every letter of a string.

  3. Use .map() to turn [1, 2, 3, 4] into [1, 4, 9, 16].

  4. Use .filter() to keep only the words with more than 4 letters from ["tree", "apple", "sun", "banana"].

  5. Use .reduce() to find the product of [2, 3, 4].

  6. Sort [40, 5, 100, 25] from smallest to largest, then from largest to smallest.

  7. Write your own myForEach(array, callback) without using the built-in forEach.

  8. Predict the output before running it, then explain why:

    console.log(["10", "10", "10"].map(parseInt));
  9. Using pipe, build cleanName that trims the text, makes it lower case, and then capitalises the first letter.

  10. Challenge: write filterBy(items, condition) and mapBy(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, like setTimeout and 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, reduce and sort. Always give sort a comparator for numbers.
  • Building map and filter yourself shows that they are just loops that call your function.
  • Function composition combines small functions: compose(f, g)(x) is f(g(x)), and pipe lists 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.