Closures
A closure is a function that has access to variables from its outer (enclosing) scope, even after that outer function has returned. This is one of JavaScript's most powerful features.
// Basic closure example
function createGreeting(greeting) {
// 'greeting' is enclosed in the returned function
return function(name) {
return `${greeting}, ${name}!`;
};
}
const sayHello = createGreeting("Hello");
const sayHi = createGreeting("Hi");
console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayHi("Bob")); // "Hi, Bob!"
// Each function remembers its own 'greeting' value
// Counter example - private state
function createCounter() {
let count = 0; // Private variable
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.getCount()); // 1
// 'count' is not accessible directly
// console.log(counter.count); // undefined
Practical Closure Examples
// Function factory - create specialized functions
function multiply(factor) {
return (number) => number * factor;
}
const double = multiply(2);
const triple = multiply(3);
const quadruple = multiply(4);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(quadruple(5)); // 20
// Memoization - caching function results
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log("Returning cached result");
return cache[key];
}
console.log("Computing result");
const result = fn(...args);
cache[key] = result;
return result;
};
}
const expensiveOperation = (n) => {
// Simulate heavy computation
return n * n;
};
const memoizedOperation = memoize(expensiveOperation);
console.log(memoizedOperation(5)); // Computing result -> 25
console.log(memoizedOperation(5)); // Returning cached result -> 25
console.log(memoizedOperation(3)); // Computing result -> 9
// Rate limiter using closure
function createRateLimiter(limit, interval) {
let calls = 0;
setInterval(() => {
calls = 0;
}, interval);
return function(fn) {
if (calls < limit) {
calls++;
return fn();
}
console.log("Rate limit exceeded");
return null;
};
}
Closure Memory
Closures keep references to variables, not copies. They can access the current value of enclosed variables, and those variables persist in memory as long as the closure exists.
Higher-Order Functions
A higher-order function is a function that takes one or more functions as arguments, returns a function, or both. They enable powerful abstraction patterns.
// Function that takes a function as argument
function repeat(n, action) {
for (let i = 0; i < n; i++) {
action(i);
}
}
repeat(3, console.log);
// 0
// 1
// 2
repeat(5, (i) => console.log(`Step ${i + 1}`));
// Step 1
// Step 2
// ... Step 5
// Function that returns a function
function greaterThan(n) {
return (m) => m > n;
}
const greaterThan10 = greaterThan(10);
console.log(greaterThan10(15)); // true
console.log(greaterThan10(5)); // false
// Function that does both
function unless(condition, action) {
if (!condition) {
action();
}
}
repeat(5, (n) => {
unless(n % 2 === 1, () => {
console.log(n, "is even");
});
});
// 0 is even
// 2 is even
// 4 is even
// Composing functions
function compose(...functions) {
return function(x) {
return functions.reduceRight((acc, fn) => fn(acc), x);
};
}
const addOne = (x) => x + 1;
const double = (x) => x * 2;
const square = (x) => x * x;
const composed = compose(square, double, addOne);
console.log(composed(3)); // ((3 + 1) * 2)² = 64
// Pipe (opposite order of compose)
function pipe(...functions) {
return function(x) {
return functions.reduce((acc, fn) => fn(acc), x);
};
}
const piped = pipe(addOne, double, square);
console.log(piped(3)); // ((3 + 1) * 2)² = 64
Callbacks
A callback is a function passed as an argument to another function, to be executed later. Callbacks are fundamental to asynchronous programming in JavaScript.
// Simple callback example
function processUserData(name, callback) {
console.log("Processing data for:", name);
callback(name);
}
processUserData("Alice", (name) => {
console.log(`Welcome, ${name}!`);
});
// Callback with error handling pattern
function divide(a, b, successCallback, errorCallback) {
if (b === 0) {
errorCallback(new Error("Cannot divide by zero"));
} else {
successCallback(a / b);
}
}
divide(10, 2,
(result) => console.log("Result:", result),
(error) => console.log("Error:", error.message)
);
// Output: Result: 5
divide(10, 0,
(result) => console.log("Result:", result),
(error) => console.log("Error:", error.message)
);
// Output: Error: Cannot divide by zero
// Simulating async operation with callback
function fetchData(id, callback) {
console.log("Fetching data...");
setTimeout(() => {
const data = { id: id, name: "User " + id };
callback(null, data); // Error-first callback pattern
}, 1000);
}
fetchData(42, (error, data) => {
if (error) {
console.log("Error:", error);
} else {
console.log("Data received:", data);
}
});
// Array methods use callbacks
const numbers = [1, 2, 3, 4, 5];
// filter uses a callback to test each element
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4]
// map uses a callback to transform each element
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
Callback Hell
Too many nested callbacks create unreadable code ("callback hell"). Modern solutions like Promises and async/await make asynchronous code much cleaner.
IIFE (Immediately Invoked Function Expression)
An IIFE is a function that runs immediately after it's defined. It's useful for creating private scopes and avoiding global namespace pollution.
// Basic IIFE syntax
(function() {
console.log("I run immediately!");
})();
// Arrow function IIFE
(() => {
console.log("Arrow IIFE!");
})();
// IIFE with parameters
(function(name) {
console.log("Hello,", name);
})("World");
// IIFE returning a value
const result = (function() {
const privateVar = "secret";
return privateVar.toUpperCase();
})();
console.log(result); // "SECRET"
// Module pattern using IIFE
const calculator = (function() {
// Private variables
let history = [];
// Private function
function addToHistory(operation) {
history.push(operation);
}
// Public interface
return {
add(a, b) {
const result = a + b;
addToHistory(`${a} + ${b} = ${result}`);
return result;
},
subtract(a, b) {
const result = a - b;
addToHistory(`${a} - ${b} = ${result}`);
return result;
},
getHistory() {
return [...history]; // Return copy
}
};
})();
console.log(calculator.add(5, 3)); // 8
console.log(calculator.subtract(10, 4)); // 6
console.log(calculator.getHistory());
// ["5 + 3 = 8", "10 - 4 = 6"]
// Private variables are inaccessible
// console.log(calculator.history); // undefined
Recursion
Recursion is when a function calls itself. It's useful for problems that can be broken down into smaller, similar subproblems.
// Factorial: n! = n × (n-1) × (n-2) × ... × 1
function factorial(n) {
// Base case
if (n <= 1) {
return 1;
}
// Recursive case
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120 (5 × 4 × 3 × 2 × 1)
// Fibonacci sequence
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(10)); // 55
// Sum of array using recursion
function sumArray(arr) {
if (arr.length === 0) return 0;
return arr[0] + sumArray(arr.slice(1));
}
console.log(sumArray([1, 2, 3, 4, 5])); // 15
// Flatten nested array
function flatten(arr) {
let result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result = result.concat(flatten(item));
} else {
result.push(item);
}
}
return result;
}
console.log(flatten([1, [2, [3, [4]], 5]]));
// [1, 2, 3, 4, 5]
// Deep clone object
function deepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item));
}
const cloned = {};
for (const key in obj) {
cloned[key] = deepClone(obj[key]);
}
return cloned;
}
const original = { a: 1, b: { c: 2, d: [3, 4] } };
const clone = deepClone(original);
clone.b.c = 999;
console.log(original.b.c); // 2 (unchanged)
Recursion Tips
- Always have a base case to stop recursion
- Make sure each recursive call moves toward the base case
- Watch for stack overflow with very deep recursion
- Consider tail call optimization for performance
The 'this' Keyword
The this keyword refers to the object that is executing the current function. Its value depends on how the function is called.
// 'this' in a method - refers to the object
const person = {
name: "John",
greet() {
console.log(`Hello, I'm ${this.name}`);
}
};
person.greet(); // "Hello, I'm John"
// 'this' in a regular function - undefined (strict) or global
function showThis() {
console.log(this);
}
showThis(); // undefined (in strict mode) or Window/global
// 'this' problem with callbacks
const timer = {
seconds: 0,
start() {
// Problem: 'this' loses context in callback
setInterval(function() {
// this.seconds++; // Error! 'this' is not timer
console.log("Regular function this:", this);
}, 1000);
}
};
// Solution 1: Arrow function (inherits 'this')
const timer2 = {
seconds: 0,
start() {
setInterval(() => {
this.seconds++; // Works! Arrow function uses outer 'this'
console.log(this.seconds);
}, 1000);
}
};
// Solution 2: Store 'this' in a variable
const timer3 = {
seconds: 0,
start() {
const self = this;
setInterval(function() {
self.seconds++;
console.log(self.seconds);
}, 1000);
}
};
// Solution 3: bind()
const timer4 = {
seconds: 0,
start() {
setInterval(function() {
this.seconds++;
console.log(this.seconds);
}.bind(this), 1000);
}
};
call(), apply(), and bind()
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const user = { name: "Alice" };
// call() - calls function with given 'this' and arguments
greet.call(user, "Hello", "!"); // "Hello, Alice!"
// apply() - like call() but takes arguments as array
greet.apply(user, ["Hi", "?"]); // "Hi, Alice?"
// bind() - returns new function with bound 'this'
const boundGreet = greet.bind(user);
boundGreet("Hey", "."); // "Hey, Alice."
// bind() with partial application
const sayHelloTo = greet.bind(user, "Hello");
sayHelloTo("!!!"); // "Hello, Alice!!!"
// Practical example: borrowing methods
const numbers = {
values: [1, 2, 3, 4, 5],
sum() {
return this.values.reduce((a, b) => a + b, 0);
}
};
const moreNumbers = {
values: [10, 20, 30]
};
// Borrow the sum method
console.log(numbers.sum.call(moreNumbers)); // 60
Currying
Currying is a technique where a function with multiple arguments is transformed into a sequence of functions, each taking a single argument.
// Regular function
function add(a, b, c) {
return a + b + c;
}
console.log(add(1, 2, 3)); // 6
// Curried version
function curriedAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
console.log(curriedAdd(1)(2)(3)); // 6
// Arrow function syntax
const curriedAddArrow = a => b => c => a + b + c;
console.log(curriedAddArrow(1)(2)(3)); // 6
// Partial application with currying
const add5 = curriedAdd(5);
const add5and10 = add5(10);
console.log(add5and10(3)); // 18
// Practical example: Logging with levels
const createLogger = (level) => (module) => (message) => {
console.log(`[${level}] [${module}] ${message}`);
};
const errorLogger = createLogger("ERROR");
const authError = errorLogger("Auth");
const dbError = errorLogger("Database");
authError("Invalid credentials");
// [ERROR] [Auth] Invalid credentials
dbError("Connection failed");
// [ERROR] [Database] Connection failed
// 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.concat(moreArgs));
};
};
}
function multiply(a, b, c) {
return a * b * c;
}
const curriedMultiply = curry(multiply);
console.log(curriedMultiply(2)(3)(4)); // 24
console.log(curriedMultiply(2, 3)(4)); // 24
console.log(curriedMultiply(2)(3, 4)); // 24
console.log(curriedMultiply(2, 3, 4)); // 24
Partial Application
Partial application is creating a new function by pre-filling some arguments of an existing function.
// Using bind() for partial application
function greet(greeting, name, punctuation) {
return `${greeting}, ${name}${punctuation}`;
}
// Pre-fill the greeting
const sayHello = greet.bind(null, "Hello");
console.log(sayHello("Alice", "!")); // "Hello, Alice!"
console.log(sayHello("Bob", "?")); // "Hello, Bob?"
// Pre-fill greeting and name
const helloAlice = greet.bind(null, "Hello", "Alice");
console.log(helloAlice("!")); // "Hello, Alice!"
console.log(helloAlice(".")); // "Hello, Alice."
// Custom partial function
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 doubleThenTriple = partial(multiply, 2, 3);
console.log(double(3, 4)); // 24 (2 * 3 * 4)
console.log(doubleThenTriple(5)); // 30 (2 * 3 * 5)
// Practical example: API request helper
function request(baseURL, method, endpoint, data) {
console.log(`${method} ${baseURL}${endpoint}`, data);
// Actual fetch logic would go here
}
// Create specialized request functions
const apiRequest = partial(request, "https://api.example.com");
const getRequest = partial(request, "https://api.example.com", "GET");
const postRequest = partial(request, "https://api.example.com", "POST");
getRequest("/users", {});
// GET https://api.example.com/users {}
postRequest("/users", { name: "John" });
// POST https://api.example.com/users { name: "John" }
Memoization
Memoization is an optimization technique that caches function results based on their arguments. It's perfect for expensive computations that are called repeatedly with the same inputs.
// Basic memoization
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log("Cache hit for:", key);
return cache.get(key);
}
console.log("Computing for:", key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Expensive fibonacci without memoization
function slowFib(n) {
if (n <= 1) return n;
return slowFib(n - 1) + slowFib(n - 2);
}
// Memoized version - dramatically faster!
const fastFib = memoize(function fib(n) {
if (n <= 1) return n;
return fastFib(n - 1) + fastFib(n - 2);
});
console.time("slowFib(35)");
console.log(slowFib(35)); // Takes several seconds!
console.timeEnd("slowFib(35)");
console.time("fastFib(35)");
console.log(fastFib(35)); // Nearly instant!
console.timeEnd("fastFib(35)");
// Memoization with WeakMap for object arguments
function memoizeWithWeakMap(fn) {
const cache = new WeakMap();
return function(obj) {
if (cache.has(obj)) {
return cache.get(obj);
}
const result = fn(obj);
cache.set(obj, result);
return result;
};
}
// Memoize with custom key function
function memoizeWithKeyFn(fn, keyFn) {
const cache = new Map();
return function(...args) {
const key = keyFn ? keyFn(...args) : JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn.apply(this, args));
}
return cache.get(key);
};
}
// Example: memoize API calls
const fetchUser = memoizeWithKeyFn(
async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
(userId) => `user-${userId}`
);
// Memoization with cache expiration
function memoizeWithTTL(fn, ttlMs = 60000) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.value;
}
const result = fn.apply(this, args);
cache.set(key, { value: result, timestamp: Date.now() });
return result;
};
}
When NOT to Memoize
- Functions with side effects (API calls without caching strategy)
- Functions that return different results for same inputs (random, Date.now)
- Functions called with unique arguments each time
- When memory is more constrained than CPU
Function Composition
Function composition is combining simple functions to build more complex ones. The output of one function becomes the input of the next.
// Simple composition (right to left)
const compose = (...fns) => (x) =>
fns.reduceRight((acc, fn) => fn(acc), x);
// Pipe (left to right - more intuitive)
const pipe = (...fns) => (x) =>
fns.reduce((acc, fn) => fn(acc), x);
// Example functions
const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;
// Using compose (right to left)
const composed = compose(square, double, addOne);
console.log(composed(2)); // 36
// Steps: 2 → addOne → 3 → double → 6 → square → 36
// Using pipe (left to right)
const piped = pipe(addOne, double, square);
console.log(piped(2)); // 36
// Same result, but reads in order of execution
// Real-world example: text processing
const trim = str => str.trim();
const toLowerCase = str => str.toLowerCase();
const replaceSpaces = str => str.replace(/\s+/g, '-');
const removeSpecialChars = str => str.replace(/[^\w-]/g, '');
const slugify = pipe(
trim,
toLowerCase,
replaceSpaces,
removeSpecialChars
);
console.log(slugify(" Hello World! How are you? "));
// "hello-world-how-are-you"
// Compose with multiple arguments (point-free style)
const map = fn => arr => arr.map(fn);
const filter = pred => arr => arr.filter(pred);
const reduce = (fn, init) => arr => arr.reduce(fn, init);
const sum = reduce((a, b) => a + b, 0);
const isEven = x => x % 2 === 0;
const doubleNum = x => x * 2;
const sumOfDoubledEvens = pipe(
filter(isEven),
map(doubleNum),
sum
);
console.log(sumOfDoubledEvens([1, 2, 3, 4, 5, 6]));
// [2, 4, 6] → [4, 8, 12] → 24
// Async composition
const pipeAsync = (...fns) => (x) =>
fns.reduce(async (acc, fn) => fn(await acc), x);
const fetchUser = async id => ({ id, name: "John" });
const fetchPosts = async user => ({ ...user, posts: ["post1", "post2"] });
const formatData = async data => ({ ...data, formatted: true });
const getUserWithPosts = pipeAsync(fetchUser, fetchPosts, formatData);
getUserWithPosts(1).then(console.log);
// { id: 1, name: "John", posts: ["post1", "post2"], formatted: true }
Trampolining
Trampolining is a technique to optimize recursive functions and avoid stack overflow by converting recursion to iteration.
// Problem: Deep recursion causes stack overflow
function factorialBad(n) {
if (n <= 1) return 1;
return n * factorialBad(n - 1);
}
// factorialBad(100000); // RangeError: Maximum call stack size exceeded
// Trampoline function
function trampoline(fn) {
return function(...args) {
let result = fn(...args);
// Keep calling while result is a function
while (typeof result === "function") {
result = result();
}
return result;
};
}
// Trampolined factorial
function factorialTrampoline(n, acc = 1) {
if (n <= 1) return acc;
// Return a function instead of calling recursively
return () => factorialTrampoline(n - 1, n * acc);
}
const factorial = trampoline(factorialTrampoline);
console.log(factorial(5)); // 120
console.log(factorial(100)); // 9.33e157
// console.log(factorial(100000)); // Works! No stack overflow
// Trampolined sum
function sumToN(n, acc = 0) {
if (n <= 0) return acc;
return () => sumToN(n - 1, acc + n);
}
const sum = trampoline(sumToN);
console.log(sum(10)); // 55
console.log(sum(10000)); // 50005000 (no stack overflow!)
// Mutual recursion with trampoline
function isEven(n) {
if (n === 0) return true;
return () => isOdd(n - 1);
}
function isOdd(n) {
if (n === 0) return false;
return () => isEven(n - 1);
}
const checkEven = trampoline(isEven);
const checkOdd = trampoline(isOdd);
console.log(checkEven(10000)); // true
console.log(checkOdd(10001)); // true
// Alternative: Convert to iteration manually
function factorialIterative(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// When to use trampolining:
// - Deep recursive algorithms
// - Tail-recursive functions
// - Mutual recursion
// - When refactoring to iteration is complex
Generator Functions
Generators are functions that can pause execution and resume later. They're perfect for lazy evaluation, infinite sequences, and implementing iterators.
// Basic generator function (note the *)
function* countUp() {
yield 1;
yield 2;
yield 3;
}
const counter = countUp();
console.log(counter.next()); // { value: 1, done: false }
console.log(counter.next()); // { value: 2, done: false }
console.log(counter.next()); // { value: 3, done: false }
console.log(counter.next()); // { value: undefined, done: true }
// Generators are iterable
for (const num of countUp()) {
console.log(num); // 1, 2, 3
}
console.log([...countUp()]); // [1, 2, 3]
// Infinite sequence (lazy evaluation)
function* infiniteNumbers(start = 0) {
let n = start;
while (true) {
yield n++;
}
}
const numbers = infiniteNumbers();
console.log(numbers.next().value); // 0
console.log(numbers.next().value); // 1
console.log(numbers.next().value); // 2
// Doesn't create infinite array - values generated on demand
// Taking N items from infinite generator
function* take(n, iterable) {
let count = 0;
for (const item of iterable) {
if (count >= n) return;
yield item;
count++;
}
}
console.log([...take(5, infiniteNumbers())]); // [0, 1, 2, 3, 4]
// Passing values INTO generators
function* conversation() {
const name = yield "What's your name?";
const age = yield `Hello, ${name}! How old are you?`;
return `${name} is ${age} years old.`;
}
const chat = conversation();
console.log(chat.next().value); // "What's your name?"
console.log(chat.next("Alice").value); // "Hello, Alice! How old are you?"
console.log(chat.next(30).value); // "Alice is 30 years old."
// Generator for fibonacci sequence
function* fibonacci() {
let [prev, curr] = [0, 1];
while (true) {
yield curr;
[prev, curr] = [curr, prev + curr];
}
}
console.log([...take(10, fibonacci())]);
// [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
// Composing generators with yield*
function* letters() {
yield 'a';
yield 'b';
yield 'c';
}
function* numbers() {
yield 1;
yield 2;
yield 3;
}
function* combined() {
yield* letters(); // Delegate to another generator
yield* numbers();
}
console.log([...combined()]); // ['a', 'b', 'c', 1, 2, 3]
// Async generators (ES2018)
async function* fetchPages(urls) {
for (const url of urls) {
const response = await fetch(url);
yield await response.json();
}
}
// Using async generator
async function processPages() {
const urls = ['/api/page/1', '/api/page/2', '/api/page/3'];
for await (const page of fetchPages(urls)) {
console.log(page);
}
}
// Custom iterable with generator
const range = {
from: 1,
to: 5,
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) {
yield i;
}
}
};
console.log([...range]); // [1, 2, 3, 4, 5]
Generator Use Cases
- Lazy sequences: Generate values on demand without memory overhead
- Infinite data: Model infinite streams like Fibonacci, primes
- Pagination: Fetch data page by page as needed
- State machines: Model complex state transitions
- Cooperative multitasking: Simulate concurrency
Summary
Closures
Functions that remember their outer scope, enabling private state and factories
Higher-Order Functions
Functions that take/return functions for powerful abstractions
Callbacks
Functions passed as arguments, executed later
IIFE
Immediately invoked for private scope
Recursion
Functions calling themselves with base cases
this & bind
Context binding with call, apply, bind