Advanced JavaScript (ES6+)

Higher-Order Functions

13 min Lesson 10 of 40

Higher-Order Functions

Higher-order functions are functions that operate on other functions, either by taking them as arguments or by returning them. This powerful concept is fundamental to functional programming and makes your code more reusable, composable, and maintainable.

Functions as First-Class Citizens

In JavaScript, functions are first-class citizens, meaning they can be treated like any other value:

// Functions can be assigned to variables const greet = function(name) { return `Hello, ${name}!`; }; // Functions can be stored in arrays const operations = [ x => x + 1, x => x * 2, x => x ** 2 ]; console.log(operations[0](5)); // 6 console.log(operations[1](5)); // 10 console.log(operations[2](5)); // 25 // Functions can be stored in objects const math = { add: (a, b) => a + b, subtract: (a, b) => a - b, multiply: (a, b) => a * b }; console.log(math.add(3, 4)); // 7 // Functions can be passed as arguments function applyOperation(x, operation) { return operation(x); } console.log(applyOperation(5, x => x * 2)); // 10
Tip: Treating functions as values opens up powerful programming patterns. You can build flexible, reusable code by passing behavior (functions) as data.

Functions Returning Functions

Higher-order functions can return new functions, creating powerful abstractions:

// Basic function factory function createMultiplier(multiplier) { return function(number) { return number * multiplier; }; } const double = createMultiplier(2); const triple = createMultiplier(3); const quadruple = createMultiplier(4); console.log(double(5)); // 10 console.log(triple(5)); // 15 console.log(quadruple(5)); // 20 // With arrow functions (more concise) const createAdder = x => y => x + y; const add5 = createAdder(5); const add10 = createAdder(10); console.log(add5(3)); // 8 console.log(add10(3)); // 13

Closures and Higher-Order Functions

Returned functions have access to the outer function's variables (closure):

// Counter with private state function createCounter(initial = 0) { let count = initial; // Private variable return { increment() { return ++count; }, decrement() { return --count; }, getCount() { return count; }, reset() { count = initial; return count; } }; } const counter = createCounter(10); console.log(counter.increment()); // 11 console.log(counter.increment()); // 12 console.log(counter.decrement()); // 11 console.log(counter.getCount()); // 11 console.log(counter.reset()); // 10 // Note: count is not directly accessible console.log(counter.count); // undefined
Key Concept: Closures allow returned functions to "remember" the environment in which they were created, enabling private state and data encapsulation.

Function Composition

Combining multiple functions to create new functionality:

// Basic composition - right to left function compose(...functions) { return function(initialValue) { return functions.reduceRight( (value, fn) => fn(value), initialValue ); }; } const addOne = x => x + 1; const double = x => x * 2; const square = x => x * x; // Compose: square(double(addOne(x))) const compute = compose(square, double, addOne); console.log(compute(2)); // ((2 + 1) * 2)^2 = 36 // Pipe - left to right (more intuitive reading) function pipe(...functions) { return function(initialValue) { return functions.reduce( (value, fn) => fn(value), initialValue ); }; } // Pipe: square(double(addOne(x))) const process = pipe(addOne, double, square); console.log(process(2)); // ((2 + 1) * 2)^2 = 36

Currying and Partial Application

Currying transforms a function with multiple arguments into a sequence of functions with single arguments:

// Manual currying function curriedAdd(a) { return function(b) { return function(c) { return a + b + c; }; }; } console.log(curriedAdd(1)(2)(3)); // 6 // Arrow function currying (more concise) const add = a => b => c => a + b + c; console.log(add(1)(2)(3)); // 6 // Generic curry function function curry(fn) { return function curried(...args) { if (args.length >= fn.length) { return fn.apply(this, args); } return function(...moreArgs) { return curried.apply(this, [...args, ...moreArgs]); }; }; } const sum = (a, b, c) => a + b + c; const curriedSum = curry(sum); console.log(curriedSum(1)(2)(3)); // 6 console.log(curriedSum(1, 2)(3)); // 6 console.log(curriedSum(1)(2, 3)); // 6 // Partial application function partial(fn, ...presetArgs) { return function(...laterArgs) { return fn(...presetArgs, ...laterArgs); }; } const multiply = (a, b, c) => a * b * c; const double = partial(multiply, 2); const triple = partial(multiply, 3); console.log(double(5, 2)); // 2 * 5 * 2 = 20 console.log(triple(5, 2)); // 3 * 5 * 2 = 30
Currying vs Partial Application: Currying always returns a function that takes exactly one argument at a time. Partial application can take multiple arguments and returns a function that expects the remaining arguments.

Memoization Patterns

Caching function results for performance optimization:

// Basic memoization function memoize(fn) { const cache = new Map(); return function(...args) { const key = JSON.stringify(args); if (cache.has(key)) { console.log('Returning cached result'); return cache.get(key); } console.log('Computing result'); const result = fn.apply(this, args); cache.set(key, result); return result; }; } // Expensive fibonacci calculation const fibonacci = memoize(function fib(n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }); console.log(fibonacci(10)); // Computing result (multiple times) console.log(fibonacci(10)); // Returning cached result console.log(fibonacci(20)); // Much faster due to memoization // Memoization with custom key generator function memoizeWith(fn, keyGenerator) { const cache = new Map(); return function(...args) { const key = keyGenerator(...args); if (cache.has(key)) { return cache.get(key); } const result = fn.apply(this, args); cache.set(key, result); return result; }; } const fetchUser = memoizeWith( (id) => ({ id, name: `User ${id}`, fetched: Date.now() }), (id) => `user_${id}` // Custom key );

Practical Higher-Order Function Examples

// 1. Debouncing - delay execution until no more calls function debounce(fn, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { fn.apply(this, args); }, delay); }; } // Usage: Search as user types const search = debounce((query) => { console.log(`Searching for: ${query}`); }, 500); search('j'); // Won't execute search('ja'); // Won't execute search('jav'); // Won't execute search('java'); // Executes after 500ms // 2. Throttling - limit execution frequency function throttle(fn, limit) { let inThrottle; return function(...args) { if (!inThrottle) { fn.apply(this, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } }; } // Usage: Scroll event handling const handleScroll = throttle(() => { console.log('Scroll event handled'); }, 1000); // 3. Once - function that can only be called once function once(fn) { let called = false; let result; return function(...args) { if (!called) { called = true; result = fn.apply(this, args); } return result; }; } const initialize = once(() => { console.log('Initializing...'); return { initialized: true }; }); console.log(initialize()); // Logs "Initializing..." and returns object console.log(initialize()); // Returns cached object without logging // 4. Retry logic function retry(fn, maxAttempts = 3, delay = 1000) { return async function(...args) { let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn.apply(this, args); } catch (error) { lastError = error; console.log(`Attempt ${attempt} failed:`, error.message); if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delay)); } } } throw new Error(`Failed after ${maxAttempts} attempts: ${lastError.message}`); }; } // Usage const unreliableAPI = retry(async () => { if (Math.random() > 0.7) { return 'Success!'; } throw new Error('Network error'); }, 5, 500);

Array Higher-Order Functions

// Built-in higher-order array methods const numbers = [1, 2, 3, 4, 5]; // map - transform each element const doubled = numbers.map(x => x * 2); console.log(doubled); // [2, 4, 6, 8, 10] // filter - select elements const evens = numbers.filter(x => x % 2 === 0); console.log(evens); // [2, 4] // reduce - accumulate values const sum = numbers.reduce((acc, x) => acc + x, 0); console.log(sum); // 15 // Chaining multiple operations const result = numbers .filter(x => x % 2 !== 0) // Odd numbers: [1, 3, 5] .map(x => x ** 2) // Square: [1, 9, 25] .reduce((acc, x) => acc + x, 0); // Sum: 35 console.log(result); // 35 // Custom array higher-order function function mapObject(obj, fn) { return Object.fromEntries( Object.entries(obj).map(([key, value]) => [key, fn(value, key)]) ); } const prices = { apple: 1, banana: 2, orange: 3 }; const discounted = mapObject(prices, price => price * 0.8); console.log(discounted); // { apple: 0.8, banana: 1.6, orange: 2.4 }

Real-World Use Cases

// 1. Middleware pattern (Express.js style) function createMiddleware() { const middlewares = []; return { use(fn) { middlewares.push(fn); }, execute(context) { return middlewares.reduce( (promise, middleware) => promise.then(() => middleware(context)), Promise.resolve() ); } }; } const app = createMiddleware(); app.use(ctx => { ctx.step1 = true; console.log('Middleware 1'); }); app.use(ctx => { ctx.step2 = true; console.log('Middleware 2'); }); // 2. Plugin system function createPluginSystem() { const plugins = []; return { register(plugin) { plugins.push(plugin); }, applyPlugins(data) { return plugins.reduce( (result, plugin) => plugin(result), data ); } }; } const system = createPluginSystem(); system.register(data => ({ ...data, processed: true })); system.register(data => ({ ...data, timestamp: Date.now() })); const result = system.applyPlugins({ value: 42 }); console.log(result); // { value: 42, processed: true, timestamp: ... } // 3. Validation chain function createValidator() { const rules = []; return { addRule(fn, errorMessage) { rules.push({ fn, errorMessage }); return this; // Chainable }, validate(value) { const errors = []; for (const { fn, errorMessage } of rules) { if (!fn(value)) { errors.push(errorMessage); } } return errors.length === 0 ? null : errors; } }; } const emailValidator = createValidator() .addRule(v => v && v.length > 0, 'Email is required') .addRule(v => v.includes('@'), 'Email must contain @') .addRule(v => v.length < 100, 'Email too long'); console.log(emailValidator.validate('test@example.com')); // null (valid) console.log(emailValidator.validate('invalid')); // ['Email must contain @']
Performance Note: While higher-order functions are powerful and elegant, be mindful of performance in hot code paths. Sometimes a simple loop is faster than multiple chained operations.

Practice Exercise:

Task: Create utility higher-order functions:

  1. Write a logger function that wraps another function and logs its inputs/outputs
  2. Create a cache function with TTL (time-to-live) support
  3. Build a compose function that works with async functions

Solution:

// 1. Logger wrapper function logger(fn, name = fn.name || 'anonymous') { return function(...args) { console.log(`[${name}] Called with:`, args); const result = fn.apply(this, args); console.log(`[${name}] Returned:`, result); return result; }; } const add = logger((a, b) => a + b, 'add'); add(2, 3); // Logs inputs and output // 2. Cache with TTL function cache(fn, ttl = 60000) { const store = new Map(); return function(...args) { const key = JSON.stringify(args); const cached = store.get(key); if (cached && Date.now() - cached.timestamp < ttl) { return cached.value; } const value = fn.apply(this, args); store.set(key, { value, timestamp: Date.now() }); // Cleanup expired entries setTimeout(() => store.delete(key), ttl); return value; }; } const fetchData = cache((id) => { console.log(`Fetching data for ${id}`); return { id, data: '....' }; }, 5000); // 3. Async compose function composeAsync(...functions) { return async function(initialValue) { let result = initialValue; for (const fn of functions.reverse()) { result = await fn(result); } return result; }; } const asyncAdd = async (x) => x + 1; const asyncDouble = async (x) => x * 2; const asyncSquare = async (x) => x ** 2; const processAsync = composeAsync(asyncSquare, asyncDouble, asyncAdd); processAsync(2).then(console.log); // ((2 + 1) * 2)^2 = 36

Summary

In this lesson, you learned:

  • Functions are first-class citizens in JavaScript
  • Higher-order functions take functions as arguments or return functions
  • Closures enable private state and data encapsulation
  • Function composition combines multiple functions into pipelines
  • Currying and partial application create specialized functions
  • Memoization caches results for performance optimization
  • Practical patterns: debounce, throttle, once, retry
  • Higher-order functions enable elegant, reusable code patterns
Next Up: In the next lesson, we'll take a deep dive into Closures and explore advanced closure patterns and use cases!

ES
Edrees Salih
17 hours ago

We are still cooking the magic in the way!