Higher-Order Functions Interview Questions & Answers

31 questions Updated 2026-06-18

JavaScript higher-order function interview questions — callbacks, map/filter/ reduce, composition, currying, partial application, memoization, debounce and throttle, plus classic callback pitfalls.

Read the in-depth guideJavaScript Higher-Order Functions — Currying, Composition, Memoization and More

A higher-order function (HOF) is a function that does at least one of two things: takes one or more functions as arguments, or returns a function. Any function that only deals with plain values is first-order.

// takes a function (callback)
[1, 2, 3].map(n => n * 2)              // map is a HOF

// returns a function
const multiplier = factor => n => n * factor  // HOF returning a HOF
const double = multiplier(2)
double(5) // 10

HOFs are possible because functions in JavaScript are first-class values — they can be stored, passed, and returned like any other value. They are the backbone of functional-style JS and enable reuse without copy-pasting logic.

A callback is a function passed into another function so it can be called back later — either synchronously (now) or asynchronously (later).

// synchronous callback
[1, 2, 3].forEach(n => console.log(n))   // called once per element, now

// asynchronous callback
setTimeout(() => console.log('later'), 0) // called after the timer fires

The function receiving the callback decides when and with what arguments to invoke it. A common gotcha: a synchronous-looking API may call the callback asynchronously, so don't assume code after the call has the callback's results yet.

map is a HOF because it accepts a function and applies it to every element, returning a new array of the results. It abstracts the loop.

const nums = [1, 2, 3]
const squared = nums.map(n => n * n)   // [1, 4, 9]
// nums is unchanged — map does not mutate

The callback receives (element, index, array). Pitfall: map is for transforming into a new array — if you only need side effects (logging, pushing to the DOM) use forEach instead, otherwise you allocate an array of undefined you never use.

filter is a HOF that takes a predicate (a function returning a boolean) and returns a new array containing only the elements for which the predicate is truthy.

const nums = [1, 2, 3, 4]
const evens = nums.filter(n => n % 2 === 0)  // [2, 4]

The result's truthiness is what matters, not strict true. Pitfall: returning a non-boolean accidentally — e.g. filter(n => n.id) keeps elements with truthy id, which silently drops items whose id is 0 or ''.

reduce is the most general array HOF: it takes a reducer function (accumulator, element) => newAccumulator plus an initial value, and folds the whole array down to a single value (which can itself be an array or object).

const sum = [1, 2, 3, 4].reduce((acc, n) => acc + n, 0)  // 10

// map and filter can both be expressed via reduce
const doubled = [1, 2, 3].reduce((acc, n) => [...acc, n * 2], []) // [2,4,6]

Pitfall: omitting the initial value. Without it, the first element becomes the accumulator and iteration starts at index 1 — and reduce on an empty array with no initial value throws a TypeError.

They separate the iteration mechanism from the per-element logic. The HOF owns the loop, bounds checking, and array allocation; you supply only the small piece that varies. This is the template method idea expressed with functions.

// imperative: loop boilerplate repeated everywhere
const out = []
for (let i = 0; i < users.length; i++) out.push(users[i].name)

// declarative: intent is obvious, no boilerplate
const names = users.map(u => u.name)   //

Because the callback is just a value, you can name it, test it in isolation, and reuse it across many call sites — reducing duplication and bugs.

Composition combines small functions into a bigger one, where the output of each becomes the input of the next — compose(f, g)(x) === f(g(x)).

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

const clean = pipe(s => s.trim(), s => s.toLowerCase())
clean('  HELLO ')  // 'hello'   reads left-to-right

compose runs right-to-left (math convention); pipe runs left-to-right (reads like a pipeline). Pitfall: each function should take and return a single value — composition breaks down with multi-arg functions unless you curry them first.

Currying transforms a function of N arguments into N nested functions that each take one argument and return the next function until all args are collected.

const add = a => b => c => a + b + c   // curried
add(1)(2)(3)   // 6

// vs the normal form
const addN = (a, b, c) => a + b + c
addN(1, 2, 3)  // 6

Currying enables partial application and clean composition, because each stage returns a specialized function. Pitfall: deeply curried APIs hurt readability and are harder to debug — reserve currying for cases where the partial-application benefit is real.

Partial application fixes some of a function's arguments now, producing a new function that takes the rest later. Currying is stricter: it always breaks a function into a chain of one-argument functions.

const greet = (greeting, name) => `${greeting}, ${name}!`
const hi = greet.bind(null, 'Hi')   // partial application via bind
hi('Sam')  // 'Hi, Sam!'   greeting already fixed

So every curried call is a partial application, but partial application can fix several arguments at once and isn't restricted to one-at-a-time. bind is the built-in tool for it (ignoring the this argument here with null).

Point-free style defines functions without naming their arguments, building them by composing other functions instead. The "point" is the data argument you don't mention.

// pointed
const isOdd = n => n % 2 === 1
const countOdds = arr => arr.filter(isOdd).length

// point-free helper composed from smaller pieces
const prop = key => obj => obj[key]
const names = users.map(prop('name'))   // no obj parameter named

It can read very cleanly, but pitfall: taken too far it becomes cryptic and stack traces lose meaningful names. Use it where it genuinely clarifies.

Memoization caches a pure function's results keyed by its arguments, so repeated calls with the same input return instantly instead of recomputing. A HOF wraps the original function and keeps the cache in a closure.

function memoize(fn) {
  const cache = new Map()
  return function (arg) {
    if (cache.has(arg)) return cache.get(arg)   // cache hit
    const result = fn.call(this, arg)
    cache.set(arg, result)
    return result
  }
}

Pitfall: it only works for pure functions (same input -> same output) and the naive version keys on a single primitive arg — multi-arg or object keys need a serialization strategy and risk unbounded memory growth.

Debounce returns a wrapped function that delays calling the original until activity has stopped for a quiet period; each new call resets the timer. It's a HOF: it takes a function and returns a closure holding the timer.

function debounce(fn, wait) {
  let timer
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), wait)   // resets
  }
}
const onSearch = debounce(query => fetchResults(query), 300)

Great for search-as-you-type or resize handlers. Pitfall: the wrapper must forward this and args with apply, or the debounced handler loses its context and the event arguments.

Throttle guarantees the function runs at most once per interval, no matter how often it's called — useful for scroll or mousemove. Debounce instead waits until calls stop. Both are HOFs returning a stateful closure.

function throttle(fn, interval) {
  let last = 0
  return function (...args) {
    const now = Date.now()
    if (now - last >= interval) {        // fire on leading edge
      last = now
      fn.apply(this, args)
    }
  }
}

Rule of thumb: throttle for steady sampling during continuous events; debounce for "do it once they're done" events.

once returns a wrapper that lets the underlying function run only the first time; later calls return the cached first result and skip execution. The "have I run yet" flag and result live in a closure.

function once(fn) {
  let called = false, result
  return function (...args) {
    if (!called) {
      called = true
      result = fn.apply(this, args)   // runs exactly once
    }
    return result
  }
}
const init = once(() => console.log('setup'))
init(); init()   // logs 'setup' only once

Handy for idempotent initialization. Pitfall: the result is cached forever — if the first call threw, decide whether called should stay false to allow a retry.

Returning a function lets you pre-configure behavior now and defer execution to later, capturing the configuration in a closure. This is the mechanism behind factories, currying, and middleware.

function makeTagger(tag) {
  return content => `<${tag}>${content}</${tag}>`   // remembers `tag`
}
const h1 = makeTagger('h1')
h1('Hello')   // '<h1>Hello</h1>'   tag captured

The returned function is specialized without you rewriting it. The key enabler is the closure: each returned function keeps its own copy of the captured configuration.

A synchronous callback runs to completion before the HOF returns (like map's callback). An asynchronous callback is scheduled and runs later, after the current call stack clears (like setTimeout or a fetch handler).

console.log('A')
[1].forEach(() => console.log('B'))     // sync -> B before C
setTimeout(() => console.log('D'), 0)   // async -> D last
console.log('C')
// order: A, B, C, D

Pitfall: mixing them in one API ("sometimes sync, sometimes async") causes subtle bugs — choose one contract. This is also why callback-based async code led to "callback hell" and motivated Promises.

map calls the callback with three arguments — (value, index, array) — but parseInt(string, radix) reads the second as a radix. So you actually call parseInt('1', 0), parseInt('2', 1), parseInt('3', 2).

['1', '2', '3'].map(parseInt)
// parseInt('1', 0) -> 1   (radix 0 ignored -> base 10)
// parseInt('2', 1) -> NaN (radix 1 invalid)
// parseInt('3', 2) -> NaN ('3' not a binary digit)
// result: [1, NaN, NaN]

Fix: wrap it so only the value is passed: ['1','2','3'].map(s => parseInt(s, 10)) -> [1, 2, 3]. The lesson: be careful passing a multi-arity built-in directly as a callback.

Passing obj.method extracts the function value alone — the obj receiver is not attached. When the HOF later calls it as a plain function, this is undefined (strict) or the global object.

const counter = {
  count: 0,
  inc() { this.count++ }
}
[1, 2].forEach(counter.inc)   // `this` is not `counter`

Fixes: bind it — forEach(counter.inc.bind(counter)) — or wrap in an arrow that preserves the receiver — forEach(() => counter.inc()). The root cause is that this is determined by how a function is called, not where it was defined.

Method chaining (arr.filter(...).map(...).reduce(...)) is composition where each step is a method returning a chainable value. Standalone composition (pipe(f, g, h)) works on free functions and any data type, not just arrays.

// chaining
const total = orders.filter(o => o.paid).map(o => o.amount)
                    .reduce((a, b) => a + b, 0)   // reads top-to-bottom

// composition with free functions
const total2 = pipe(
  os => os.filter(o => o.paid),
  os => os.map(o => o.amount),
  os => os.reduce((a, b) => a + b, 0)
)(orders)

Chaining is ergonomic when the methods exist; composition is more general and decouples logic from the data's prototype. Pitfall: long chains over large arrays create many intermediate arrays.

flatMap is a HOF that maps then flattens one level in a single pass. It's equivalent to arr.map(fn).flat() but more efficient and expressive.

const sentences = ['hello world', 'foo bar']
const words = sentences.flatMap(s => s.split(' '))
// ['hello', 'world', 'foo', 'bar']   flattened

// also handy to map-and-filter: return [] to drop
[1, 2, 3].flatMap(n => n % 2 ? [n] : [])  // [1, 3]

Pitfall: it only flattens one level — deeply nested results need flat(Infinity) afterwards.

Both are HOFs taking a predicate. every returns true only if the predicate passes for all elements; some returns true if at least one passes. Both short-circuit.

[2, 4, 6].every(n => n % 2 === 0)  // true
[1, 2, 3].some(n => n > 2)         // true (stops at 3)

every stops at the first falsy result; some stops at the first truthy result. Edge case: [].every(...) is true (vacuous truth) and [].some(...) is false.

Both fix the receiver. bind creates a new bound function once, ideal when you need a stable reference (e.g. to later removeEventListener). An arrow wrapper is created fresh each render/call and is convenient for forwarding changing arguments.

// stable, removable
const handler = this.onClick.bind(this)
el.addEventListener('click', handler)
el.removeEventListener('click', handler)   // same reference

// arrow re-created each time -> cannot remove
el.addEventListener('click', () => this.onClick())  // no handle to remove

Pitfall: binding inside JSX/render or in a loop creates a new function every time, which can defeat memoization and break listener removal.

identity = x => x returns its argument unchanged; constant = x => () => x returns a function that always yields x. They are tiny building blocks that make HOF pipelines uniform.

const identity = x => x
// filter out falsy values with no custom predicate
['a', '', 'b', null].filter(identity)   // ['a', 'b']

const always5 = (() => () => 5)()
[1, 2, 3].map(always5)   // [5, 5, 5]

They shine as default callbacks (e.g. a sort key defaulting to identity) and in composition where you need a no-op transform.

Data-last means the data parameter comes last, so the earlier configuration args can be partially applied to build reusable, composable functions. The data flows in only at the end of a pipeline.

const map = fn => arr => arr.map(fn)        // data (arr) last
const filter = pred => arr => arr.filter(pred)

const process = pipe(filter(x => x > 0), map(x => x * 2))
process([-1, 2, 3])   // [4, 6]   data supplied last

Native array methods are data-first (arr.map(fn)), which is why you often wrap them. The trade-off: data-last composes beautifully but reads backwards from the method style most JS developers know.

The Node-style error-first callback convention passes the error as the first argument and the result(s) afterward: callback(err, data). If err is truthy, something failed.

fs.readFile('x.txt', (err, data) => {
  if (err) return handle(err)   // check error first
  use(data)
})

Pitfall: forgetting to return after handling the error lets the success path run with undefined data. This convention predates Promises, which largely replaced it with .then/.catch and async/await.

Many array HOFs accept an optional thisArg as their last parameter. When provided (and the callback is a regular function, not an arrow), this inside the callback is set to it.

const ctx = { factor: 3 }
[1, 2, 3].map(function (n) { return n * this.factor }, ctx)  // [3, 6, 9]

Pitfall: thisArg is ignored by arrow callbacks, because arrows take this lexically. Most modern code just uses an arrow that closes over the value instead of relying on thisArg.

Plain compose/pipe assume synchronous functions. For async, each step returns a Promise, so you await between stages — often by reducing over an initial resolved Promise.

const pipeAsync = (...fns) => x =>
  fns.reduce((acc, fn) => acc.then(fn), Promise.resolve(x))

const run = pipeAsync(fetchUser, u => fetchPosts(u.id), posts => posts.length)
run(42).then(console.log)   // each step awaits the previous

Pitfall: mixing sync and async functions in one pipe is fine here because then auto-wraps non-Promise returns — but an unhandled rejection in any stage rejects the whole chain, so attach a .catch.

A generic curry collects arguments until it has received as many as the function's declared arity (fn.length), then invokes it. Until then it returns a function that accumulates more args.

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn.apply(this, args)
    return (...next) => curried.apply(this, [...args, ...next])   //
  }
}
const add = curry((a, b, c) => a + b + c)
add(1)(2)(3)   // 6
add(1, 2)(3)   // 6   — flexible grouping

Pitfall: it relies on fn.length, which excludes rest params and parameters with defaults — so currying variadic functions doesn't work without passing an explicit arity.

Instead of subclassing to share behavior, you can wrap functions with HOFs (decorators) to add cross-cutting concerns like logging, caching, or retries — composition over inheritance.

const withLogging = fn => (...args) => {
  console.log('call', fn.name, args)
  return fn(...args)
}
const withRetry = fn => async (...args) => {
  try { return await fn(...args) } catch { return fn(...args) }
}
const robustFetch = withLogging(withRetry(fetchData))   // stacked

Each concern is an independent, testable HOF you can stack in any order. This avoids deep inheritance hierarchies and the fragile base-class problem.

tap runs a side effect (like logging) on a value and then returns the value unchanged, so you can drop it into a composition without altering the data flow.

const tap = fn => x => { fn(x); return x }

const result = pipe(
  x => x + 1,
  tap(x => console.log('after +1:', x)),   // peeks, passes through
  x => x * 2
)(5)   // logs 6, result 12

It's the functional equivalent of a breakpoint. Pitfall: because it's for side effects, don't accidentally return fn(x) — that would replace the value and corrupt the pipeline.

If a HOF closes over a shared mutable variable and returns functions that all reference it, they can interfere with each other — the classic loop-closure trap.

function makeHandlers() {
  const fns = []
  for (var i = 0; i < 3; i++) fns.push(() => i)   // all see final i
  return fns
}
makeHandlers().map(f => f())   // [3, 3, 3]

Fix: use let (per-iteration binding) so each closure captures its own i -> [0, 1, 2]. The deeper lesson: when a HOF returns multiple closures, be deliberate about whether they should share or own their captured state.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.