Engineering Notes
Home/Languages/JavaScript/Higher-Order Functions
Languages · JavaScript

Higher-Order Functions

A higher-order function is any function that does at least one of: 1. Takes another function as an argument, or 2. Returns a function as its result.

💻 Languages● Beginner⏱ 5 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Definition
  • Built-in Higher-Order Functions You Already Use
  • Function Composition
  • Returning Functions: Configuration and Specialization
  • Decorators (Wrapping Functions)

1Definition

A higher-order function is any function that does at least one of:

  1. Takes another function as an argument, or
  2. Returns a function as its result.

Functions are first-class values in JavaScript — they can be stored in variables, passed around, and returned, exactly like numbers or strings. This is what makes higher-order functions possible.

js
function applyTwice(fn, value) {   // takes a function as an argument
  return fn(fn(value));
}
applyTwice(x => x * 2, 3); // 12

function multiplier(factor) {       // returns a function
  return x => x * factor;
}
const triple = multiplier(3);
triple(5); // 15

2Built-in Higher-Order Functions You Already Use

map, filter, reduce, forEach, sort (with a comparator), setTimeout, and addEventListener are all higher-order functions — they accept a callback function as an argument.

js
[1, 2, 3].map(n => n * 2);              // map is higher-order — takes a function
setTimeout(() => console.log("done"), 1000); // setTimeout is higher-order too

3Function Composition

Combining small, single-purpose functions into a pipeline is the most practical use of higher-order functions:

js
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

const trim = s => s.trim();
const lower = s => s.toLowerCase();
const exclaim = s => s + "!";

const shout = pipe(trim, lower, exclaim);
shout("  HELLO  "); // "hello!"

pipe runs left to right; compose runs right to left — both are standard names in functional-programming-flavored JavaScript.

pipe(trim, lower, exclaim) feeding "  HI  " through each function in sequence to produce "hi!"
pipe(trim, lower, exclaim) feeding " HI " through each function in sequence to produce "hi!"

4Returning Functions: Configuration and Specialization

js
function createValidator(minLength) {
  return function (value) {
    return typeof value === "string" && value.length >= minLength;
  };
}

const isValidPassword = createValidator(8);
isValidPassword("short");     // false
isValidPassword("longenough"); // true

This pattern — a function that returns a specialized function — is the basis of currying, decorators, and middleware chains (Express middleware and Redux middleware both rely on this exact shape).

5Decorators (Wrapping Functions)

A higher-order function can wrap another function to add behavior without modifying its source:

js
function withLogging(fn) {
  return function (...args) {
    console.log(`Calling ${fn.name} with`, args);
    const result = fn(...args);
    console.log(`${fn.name} returned`, result);
    return result;
  };
}

const add = (a, b) => a + b;
const loggedAdd = withLogging(add);
loggedAdd(2, 3); // logs the call, then returns 5

This is the same principle behind memoize (Closures topic), debounce, and throttle.

6Debounce and Throttle — Practical Higher-Order Functions

js
function debounce(fn, delayMs) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
  };
}

debounce returns a new function that resets its timer on every call, only firing fn once activity has paused — the standard fix for excessive input/resize event handling.

Under the HoodCall-Site Monomorphism for Callbacks▾

The Functions topic's tiered compilation pipeline and the Prototype Chain topic's inline caches both apply directly to callback call sites — and this is where higher-order functions can quietly cost more than a hand-written loop, or, when the engine can optimize well, cost almost nothing extra at all.

Inlining: erasing the function-call overhead entirely

When a call site like arr.map(callback) consistently receives the same callback function every time it runs, the optimizing compiler can inline that callback directly into map's own optimized code — eliminating the function-call overhead altogether, so the generated machine code ends up close to what a manual for loop would produce.

js
function double(x) { return x * 2; }
const result = [1, 2, 3].map(double); // same `double` function every time this line runs -> inlinable

Megamorphic callback sites lose this optimization

js
function runTransform(arr, transformFn) {
  return arr.map(transformFn); // `transformFn` is a DIFFERENT function object on every call
}
runTransform(data1, x => x + 1);
runTransform(data2, x => x * 2);
runTransform(data3, x => x.toUpperCase());

Here, the .map() call site inside runTransform sees a different callback identity every invocation — it can never safely assume "it's always this one function," so it can't inline, and falls back to a real indirect function call each time. This is the same monomorphic/polymorphic/megamorphic spectrum from the Prototype Chain topic, applied to functions as the varying thing instead of object shapes.

Why this rarely matters, and when it does

For the overwhelming majority of code, this distinction is invisible — a few extra function-call frames per iteration is negligible next to real work (network calls, DOM updates, business logic). It becomes worth knowing specifically in hot paths: tight loops over large arrays, called extremely often, where a highly polymorphic higher-order pattern (a different closure created and passed in on every call, as debounce/memoize's returned functions are) can prevent the kind of inlining that makes functional-style code as fast as imperative code in the first place.

Closures returned from decorators are fresh objects every time

Every call to debounce(fn, delayMs) (section 6) or memoize(fn) allocates a brand-new function object with its own Context (see the Closures topic) — calling a decorator factory repeatedly (e.g. inside a loop, or on every render in a UI framework) creates a new, independent closure each time, none of which share state with each other, and each of which represents a fresh object the engine has to track from scratch for optimization purposes.

⚠️ Common Pitfalls

  1. Losing this inside a returned function — if the wrapped function relies on this, use fn.apply(this, args) or convert to arrow functions consistently (see the this keyword topic).
  2. Over-composing for its own sake — a 6-function pipe chain that's only used once is often less readable than a plain sequence of statements; reserve composition for genuinely reused logic.
  3. Forgetting closures persist state between calls — a decorator like debounce keeps timeoutId alive across every call via closure; creating a new debounced function on every render (e.g. inline in a React component) defeats the purpose because the closure resets each time.
  4. Confusing "takes a callback" with "is asynchronous" — map/filter/sort are higher-order but fully synchronous; higher-order-ness and async-ness are unrelated concepts.

📋 Quick Reference

text
higher-order function = takes a function in, OR returns a function out (or both)
pipe(f, g, h)(x)  = h(g(f(x)))     — left to right
compose(f, g, h)(x) = f(g(h(x)))   — right to left

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a higher-order function?core▾

A function that takes functions as arguments or returns a function: map, filter, reduce, setTimeout, decorators like debounce, memoize, and middleware.

Follow-ups interviewers ask
  • Why useful? Abstracting behaviour and composing small functions.
Q2Implement debounce.core▾

function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } Calls run only after ms of silence.

Follow-ups interviewers ask
  • Debounce vs throttle? Debounce waits for quiet; throttle runs at most once per interval.
Q3Implement memoize for single-argument functions.core▾

function memoize(fn) { const cache = new Map(); return x => { if (!cache.has(x)) cache.set(x, fn(x)); return cache.get(x); }; }

Follow-ups interviewers ask
  • Risks? Unbounded cache growth; use LRU or WeakMap for object keys.
Q4What is function composition?senior▾

Combining functions so the output of one feeds the next: compose(f, g)(x) = f(g(x)), often implemented with reduceRight; pipe applies left to right.

Follow-ups interviewers ask
  • Where is it common? Functional libraries, middleware pipelines.
Q5Why can chaining map/filter/reduce be inefficient?core▾

Each step creates an intermediate array and iterates again. For huge datasets or hot paths, a single loop or iterator helpers/lazy pipelines avoid extra allocations; for normal sizes readability wins.

Follow-ups interviewers ask
  • Iterator helpers? New .map/.filter on iterators evaluate lazily.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.