JavaScript Functions: Function Definitions · Advanced Patterns · Callbacks · `this` · call · apply · bind · IIFE · Closures · Reference
How to use this tutorial This tutorial covers every function concept in JavaScript from the ground up. Each chapter builds on the previous. Work through in order — concepts from Chapter 1 are used in Chapter 9.
- Phase 1 – Comprehension: What it is, how every line works, why it matters
- Phase 2 – Practice: Real-world exercises with hints and self-checks
- Phase 3 – Creation: A full project bringing all concepts together
TABLE OF CONTENTS
- Chapter 1 — Function Definitions
- Chapter 2 — Advanced Function Patterns
- Chapter 3 — Callbacks
- Chapter 4 — The
thisKeyword - Chapter 5 — Function.call()
- Chapter 6 — Function.apply()
- Chapter 7 — Function.bind()
- Chapter 8 — IIFE — Immediately Invoked Function Expressions
- Chapter 9 — Closures
- Chapter 10 — Function Reference (Built-In Methods)
- Phase 2 — Applied Exercises
- Phase 3 — Project Simulation
- Quiz & Completion Checklist
CHAPTER 1 — FUNCTION DEFINITIONS
What Is a Function?
A function is a named, reusable block of code that performs a specific task. Instead of writing the same instructions over and over, you write them once inside a function and call the function whenever you need it.
Real-world analogy: A function is like a recipe card. The recipe is written once. You can follow it as many times as you want, using different ingredients (arguments) each time, and it always produces the same kind of result.
Functions solve three fundamental problems in programming:
| Problem | How Functions Help |
|---|---|
| Repetition | Write once, call many times |
| Organisation | Group related code under a meaningful name |
| Abstraction | Use complex logic without needing to understand the internals every time |
1.1 — Function Declaration (Named Function Statement)
This is the most traditional way to define a function.
function greet(name) {
return "Hello, " + name + "!";
}
console.log(greet("Tunde")); // Output: Hello, Tunde!
console.log(greet("Sara")); // Output: Hello, Sara!
Anatomy of a function declaration:
function greet (name) {
│ │ │ │
keyword name parameter(s) body begins
| Part | Description |
|---|---|
function |
Keyword that tells JavaScript a function is being defined |
greet |
The name of the function — used to call it later |
(name) |
Parameters — placeholder variables for values passed in |
{ ... } |
The body — the instructions that run when the function is called |
return |
Sends a value back to whoever called the function |
💡 Parameters vs Arguments — what’s the difference?
- Parameters are the placeholder names listed in the function definition:
function greet(name)—nameis a parameter.- Arguments are the actual values passed in when you call the function:
greet("Tunde")—"Tunde"is an argument. Think of a parameter as a blank form field, and an argument as the value you write in it.
Key feature — hoisting:
Function declarations are hoisted, which means JavaScript moves them to the top of the current scope before running any code. You can call a declared function before it appears in the file:
console.log(add(3, 4)); // Output: 7 ✅ — works even though the function is below
function add(a, b) {
return a + b;
}
1.2 — Function Expression
A function expression defines a function and assigns it to a variable.
const multiply = function(a, b) {
return a * b;
};
console.log(multiply(4, 5)); // Output: 20
The function on the right side of = has no name — it is an anonymous function. The variable multiply holds a reference to it.
⚠️ Function expressions are NOT hoisted:
console.log(multiply(4, 5)); // ❌ ReferenceError: Cannot access 'multiply' before initialization
const multiply = function(a, b) {
return a * b;
};
The variable multiply exists, but its value (the function) hasn’t been assigned yet at the point of the call.
Named function expression:
You can give the function inside an expression a name, which is only visible inside the function itself (useful for recursion):
const factorial = function fact(n) {
if (n <= 1) return 1;
return n * fact(n - 1); // 'fact' is accessible here
};
console.log(factorial(5)); // Output: 120
// console.log(fact(5)); // ❌ ReferenceError — 'fact' not visible outside
1.3 — Arrow Functions (ES6)
Arrow functions are a shorter syntax introduced in ES2015 (ES6). They are especially popular in modern JavaScript.
Basic syntax:
const square = (n) => {
return n * n;
};
console.log(square(6)); // Output: 36
Shorthand rules:
| Situation | Shorthand |
|---|---|
| Single parameter | Parentheses optional: n => n * n |
| Single-line body with a return | Curly braces and return optional (implicit return) |
| No parameters | Empty parentheses required: () => "Hello" |
// Full syntax:
const square = (n) => { return n * n; };
// Concise syntax (equivalent):
const square = n => n * n;
console.log(square(7)); // Output: 49
Multi-parameter arrow function:
const add = (a, b) => a + b;
console.log(add(10, 3)); // Output: 13
⚠️ Critical difference: Arrow functions and
thisArrow functions do NOT have their ownthisbinding — they inheritthisfrom the surrounding context. This is explored in depth in Chapter 4. For now, just know that arrow functions and regular functions behave differently when used inside objects or event handlers.
1.4 — Default Parameter Values (ES6)
If a caller doesn’t pass a value for a parameter, you can provide a fallback:
function greet(name = "Guest") {
return "Welcome, " + name + "!";
}
console.log(greet("Amara")); // Output: Welcome, Amara!
console.log(greet()); // Output: Welcome, Guest!
How defaults work:
Default values are used when the argument is undefined — either because it was not passed at all, or was explicitly passed as undefined. Passing null does NOT trigger the default:
function show(value = "default") {
console.log(value);
}
show(); // Output: default ← argument is missing (undefined)
show(undefined); // Output: default ← explicitly undefined
show(null); // Output: null ← null is a real value, not undefined
show("hello"); // Output: hello
1.5 — Rest Parameters (ES6)
Rest parameters allow a function to accept any number of arguments, collected into an array.
function sum(...numbers) {
let total = 0;
for (const n of numbers) {
total += n;
}
return total;
}
console.log(sum(1, 2)); // Output: 3
console.log(sum(1, 2, 3, 4, 5)); // Output: 15
console.log(sum()); // Output: 0
The ... before numbers says “collect all remaining arguments into an array named numbers.”
⚠️ Rules for rest parameters:
- The rest parameter must be the last parameter:
function f(a, b, ...rest)✅- There can only be one rest parameter per function
function f(...rest, a)❌ SyntaxError
Combining regular and rest parameters:
function introduce(greeting, ...names) {
names.forEach(name => console.log(greeting + ", " + name + "!"));
}
introduce("Hello", "Alice", "Bob", "Charlie");
// Output:
// Hello, Alice!
// Hello, Bob!
// Hello, Charlie!
1.6 — The arguments Object (Classic Functions Only)
Before rest parameters existed, all regular functions had access to an arguments object — an array-like object containing all passed arguments.
function logAll() {
console.log(arguments.length); // How many arguments were passed
for (let i = 0; i < arguments.length; i++) {
console.log(arguments[i]);
}
}
logAll("a", "b", "c");
// Output:
// 3
// a
// b
// c
⚠️
argumentsis NOT available in arrow functions. Arrow functions have noargumentsobject — use rest parameters instead.
| Feature | arguments object |
Rest parameters (...args) |
|---|---|---|
| Type | Array-like object | Real array |
| Arrow function support | ❌ No | ✅ Yes |
Array methods (.map, .filter) |
❌ Not directly | ✅ Yes |
| Modern best practice | Avoid | Preferred |
1.7 — Functions Are First-Class Citizens
In JavaScript, functions are first-class citizens — they are treated like any other value. This means you can:
// 1. Assign a function to a variable
const sayHi = function() { return "Hi!"; };
// 2. Pass a function as an argument to another function
function callIt(fn) {
return fn();
}
console.log(callIt(sayHi)); // Output: Hi!
// 3. Return a function from another function
function makeMultiplier(x) {
return function(y) {
return x * y;
};
}
const triple = makeMultiplier(3);
console.log(triple(5)); // Output: 15
console.log(triple(10)); // Output: 30
// 4. Store functions in arrays and objects
const operations = [
(a, b) => a + b,
(a, b) => a - b,
(a, b) => a * b,
];
console.log(operations[0](10, 4)); // Output: 14
console.log(operations[2](10, 4)); // Output: 40
This is the foundation of functional programming and makes concepts like callbacks and closures possible.
1.8 — Return Values and undefined
Every function returns a value. If you don’t write a return statement, the function returns undefined automatically.
function noReturn() {
let x = 5; // Does something but returns nothing
}
const result = noReturn();
console.log(result); // Output: undefined
return also stops the function immediately:
function checkAge(age) {
if (age < 0) {
return "Invalid age"; // Exits here — nothing below runs
}
if (age < 18) {
return "Minor";
}
return "Adult";
}
console.log(checkAge(-1)); // Output: Invalid age
console.log(checkAge(15)); // Output: Minor
console.log(checkAge(25)); // Output: Adult
🤔 Thinking question: What is the return value of
console.log("Hello")? Tryconst x = console.log("Hello"); console.log(x);— what do you get?
CHAPTER 2 — ADVANCED FUNCTION PATTERNS
What Are Advanced Function Patterns?
Once you understand the basics of defining and calling functions, JavaScript opens up more sophisticated techniques. These patterns appear constantly in professional code, frameworks like React, and libraries like Lodash.
2.1 — Higher-Order Functions
A higher-order function is a function that either:
- Takes one or more functions as arguments, OR
- Returns a function as its result (or both)
You already know addEventListener — it’s a higher-order function. It accepts a function (the callback) as its second argument.
Micro-demo — accepting a function:
function applyTwice(fn, value) {
return fn(fn(value)); // Call fn on value, then call fn on the result
}
const double = n => n * 2;
console.log(applyTwice(double, 3)); // Output: 12 (3×2=6, 6×2=12)
console.log(applyTwice(double, 5)); // Output: 20 (5×2=10, 10×2=20)
Micro-demo — returning a function (function factory):
function makeGreeter(greeting) {
return function(name) {
return greeting + ", " + name + "!";
};
}
const sayGoodMorning = makeGreeter("Good morning");
const sayGoodNight = makeGreeter("Good night");
console.log(sayGoodMorning("Tunde")); // Output: Good morning, Tunde!
console.log(sayGoodNight("Sara")); // Output: Good night, Sara!
makeGreeter is a function factory — a function that produces customised functions.
2.2 — Pure Functions
A pure function is a function that:
- Given the same input, always returns the same output
- Has no side effects (does not change anything outside itself)
// Pure function — predictable, no external impact
function add(a, b) {
return a + b;
}
// Impure function — depends on external variable; changes it
let total = 0;
function addToTotal(n) {
total += n; // Side effect: changes a variable outside the function
return total;
}
console.log(add(2, 3)); // Always: 5
console.log(add(2, 3)); // Always: 5
addToTotal(5); // total is now 5
addToTotal(5); // total is now 10 — same call, different result!
💡 Why care about pure functions?
- They are easier to test (no hidden state)
- They are predictable (same input = same output always)
- They are safe to reuse without worrying about side effects Professional JavaScript uses pure functions wherever possible.
2.3 — Recursive Functions
A recursive function is one that calls itself. It must have a base case that stops the recursion; otherwise, it runs forever and causes a stack overflow.
function countdown(n) {
if (n <= 0) { // Base case: stop here
console.log("Done!");
return;
}
console.log(n);
countdown(n - 1); // Recursive call: same function with a smaller problem
}
countdown(5);
// Output:
// 5
// 4
// 3
// 2
// 1
// Done!
Classic example — factorial:
function factorial(n) {
if (n <= 1) return 1; // Base case
return n * factorial(n - 1); // Recursive case
}
console.log(factorial(5)); // Output: 120 (5×4×3×2×1)
Recursive breakdown for factorial(4):
factorial(4)
→ 4 * factorial(3)
→ 3 * factorial(2)
→ 2 * factorial(1)
→ 1 (base case)
→ 2 * 1 = 2
→ 3 * 2 = 6
→ 4 * 6 = 24
2.4 — Function Composition
Function composition means combining two or more functions so the output of one becomes the input of the next — like a pipeline.
const double = n => n * 2;
const addTen = n => n + 10;
const stringify = n => "Result: " + n;
// Manual composition:
const result = stringify(addTen(double(5)));
console.log(result); // Output: Result: 20 (5→10→20→"Result: 20")
// Reusable compose function:
function compose(...fns) {
return function(value) {
return fns.reduceRight((acc, fn) => fn(acc), value);
// Applies functions right to left: stringify(addTen(double(value)))
};
}
const transform = compose(stringify, addTen, double);
console.log(transform(5)); // Output: Result: 20
console.log(transform(10)); // Output: Result: 30
💡 Real-world analogy: Function composition is like an assembly line. Each station (function) does one job and passes the result to the next. This produces clean, modular code where each function has a single responsibility.
2.5 — Memoisation (Caching Results)
Memoisation is an optimisation technique where a function caches its results so repeated calls with the same arguments return instantly from the cache instead of recomputing.
function memoize(fn) {
const cache = {};
return function(n) {
if (cache[n] !== undefined) {
console.log("From cache: " + n);
return cache[n]; // Return cached result
}
const result = fn(n);
cache[n] = result; // Store in cache
return result;
};
}
function slowSquare(n) {
// Simulate slow computation
return n * n;
}
const fastSquare = memoize(slowSquare);
console.log(fastSquare(5)); // Computed: 25
console.log(fastSquare(5)); // From cache: 25 (instant)
console.log(fastSquare(6)); // Computed: 36
console.log(fastSquare(6)); // From cache: 36 (instant)
🤔 Thinking question: In the
memoizefunction, why doescachepersist between calls even thoughmemoizehas finished running? (Hint: this is a closure — Chapter 9 explains exactly why.)
CHAPTER 3 — CALLBACKS
What Is a Callback Function?
A callback is a function passed as an argument to another function, to be called (executed) later — either immediately or after an asynchronous operation completes.
Real-world analogy: You call a restaurant to order food. Instead of waiting silently on the phone until the food is ready (blocking), you give the restaurant your phone number (the callback). They call you back when the food is ready. You can do other things in the meantime.
function processOrder(item, callback) {
console.log("Processing order for: " + item);
callback(item); // Call the function that was passed in
}
function deliver(item) {
console.log(item + " has been delivered!");
}
processOrder("Pizza", deliver);
// Output:
// Processing order for: Pizza
// Pizza has been delivered!
3.1 — Synchronous Callbacks
A synchronous callback is called immediately — the outer function doesn’t move to the next line until the callback has finished.
function greetUser(name, formatter) {
const formatted = formatter(name); // Call the callback right now
console.log("Hello, " + formatted);
}
greetUser("tunde", name => name.toUpperCase());
// Output: Hello, TUNDE
greetUser("sara", name => name[0].toUpperCase() + name.slice(1));
// Output: Hello, Sara
Built-in array methods like .forEach(), .map(), .filter(), and .sort() all use synchronous callbacks:
const numbers = [1, 2, 3, 4, 5];
// forEach: calls the callback for each element
numbers.forEach(function(n) {
console.log(n * 10);
});
// Output: 10, 20, 30, 40, 50
// map: calls the callback for each element, collects results into a new array
const doubled = numbers.map(n => n * 2);
console.log(doubled); // Output: [2, 4, 6, 8, 10]
// filter: keeps elements for which the callback returns true
const evens = numbers.filter(n => n % 2 === 0);
console.log(evens); // Output: [2, 4]
3.2 — Asynchronous Callbacks
An asynchronous callback is called later — after some time has passed or after an operation completes. The rest of the code continues running in the meantime.
console.log("Start");
setTimeout(function() {
console.log("This runs after 2 seconds");
}, 2000);
console.log("End");
// Output:
// Start
// End
// (2 seconds later) This runs after 2 seconds
setTimeout is a built-in function that calls a callback after a delay. Notice that “End” prints before the callback — JavaScript did not wait.
setInterval — repeated callbacks:
let count = 0;
const interval = setInterval(function() {
count++;
console.log("Tick " + count);
if (count === 3) {
clearInterval(interval); // Stop after 3 ticks
console.log("Done");
}
}, 1000);
// Output (one per second):
// Tick 1
// Tick 2
// Tick 3
// Done
3.3 — Callback Hell and How to Avoid It
When multiple asynchronous operations depend on each other, callbacks can nest deeply — this is called callback hell or the pyramid of doom:
// ❌ Callback hell — hard to read and maintain
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getProductDetails(orders[0].productId, function(product) {
getReviews(product.id, function(reviews) {
console.log(reviews);
// Nested 4 levels deep — and it can get worse
});
});
});
});
Solutions:
- Named functions — pull callbacks out of the nesting:
function handleReviews(reviews) {
console.log(reviews);
}
function handleProduct(product) {
getReviews(product.id, handleReviews);
}
function handleOrders(orders) {
getProductDetails(orders[0].productId, handleProduct);
}
function handleUser(user) {
getOrders(user.id, handleOrders);
}
getUser(userId, handleUser);
// Same logic, but flat and readable
- Promises (modern approach — each step returns a promise instead of accepting a callback)
- async/await (the cleanest modern syntax — built on top of promises)
💡 Real-world relevance: Callback hell is why Promises and async/await were invented. Every modern JavaScript developer encounters this pattern. Understanding the problem is essential for understanding why the solutions exist.
3.4 — Error-First Callbacks (Node.js Convention)
In Node.js and many libraries, callbacks follow a convention: the first argument is always an error (or null if no error occurred), and subsequent arguments are the results.
function readFile(filename, callback) {
// Simulate reading a file
const error = filename === "missing.txt" ? new Error("File not found") : null;
const data = error ? null : "File contents here";
callback(error, data);
}
readFile("hello.txt", function(err, data) {
if (err) {
console.log("Error:", err.message);
return; // Stop processing
}
console.log("Data:", data);
});
// Output: Data: File contents here
readFile("missing.txt", function(err, data) {
if (err) {
console.log("Error:", err.message);
return;
}
console.log("Data:", data);
});
// Output: Error: File not found
Always check for errors first before using the data — this prevents crashes when operations fail.
CHAPTER 4 — THE this KEYWORD
What Is this?
this is a special keyword in JavaScript that refers to the object that is currently executing the code. Its value is not fixed — it changes depending on how and where the function is called.
This makes this one of the most confusing concepts for beginners, but also one of the most powerful tools in JavaScript.
Core rule: this is not determined by where a function is written. It is determined by how the function is called.
4.1 — this in a Regular Function (Non-Strict Mode)
In a regular function called without any object context, this refers to the global object:
- In a browser:
thisis thewindowobject - In Node.js:
thisis theglobalobject
function showThis() {
console.log(this);
}
showThis(); // Output: Window {...} (in a browser)
4.2 — this in an Object Method
When a function is called as a method of an object, this refers to that object:
const person = {
name: "Tunde",
greet: function() {
console.log("Hello, my name is " + this.name);
}
};
person.greet(); // Output: Hello, my name is Tunde
this.name inside greet refers to person.name because greet is being called as a method of person.
Micro-demo — this changes with how you call the function:
const person = {
name: "Tunde",
greet: function() {
console.log(this.name);
}
};
person.greet(); // Output: Tunde ← called as a method
const fn = person.greet;
fn(); // Output: undefined ← called as a plain function
In the second call, the function is extracted from the object and called on its own. It’s no longer a method call, so this is no longer person.
4.3 — this in a Constructor Function
When a function is used as a constructor (called with new), this refers to the newly created object:
function Car(make, model) {
this.make = make; // Attach 'make' to the new object
this.model = model; // Attach 'model' to the new object
this.describe = function() {
return this.make + " " + this.model;
};
}
const myCar = new Car("Toyota", "Corolla");
console.log(myCar.describe()); // Output: Toyota Corolla
console.log(myCar.make); // Output: Toyota
When new Car("Toyota", "Corolla") runs:
- A blank object is created
thisinsideCarpoints to that new object- Properties are attached to it
- The new object is returned automatically
4.4 — this in Event Listeners
In a DOM event listener using a regular function, this refers to the element that fired the event:
const btn = document.getElementById("my-button");
btn.addEventListener("click", function() {
console.log(this); // Output: <button id="my-button">...</button>
this.style.background = "red"; // Changes the clicked button's colour
});
This is very useful for writing one handler that can work on multiple elements.
4.5 — this in Arrow Functions
Arrow functions do not have their own this. They inherit this from the enclosing lexical scope — the this value from the context where the arrow function was defined.
const team = {
name: "Dev Team",
members: ["Alice", "Bob", "Charlie"],
listMembers: function() {
// 'this' here is 'team' (regular function as a method)
this.members.forEach(member => {
// Arrow function inherits 'this' from listMembers → 'this' is still 'team'
console.log(member + " is on " + this.name);
});
}
};
team.listMembers();
// Output:
// Alice is on Dev Team
// Bob is on Dev Team
// Charlie is on Dev Team
What would go wrong with a regular function inside forEach:
listMembers: function() {
this.members.forEach(function(member) {
// Regular function — 'this' is now the global object, NOT 'team'
console.log(member + " is on " + this.name); // this.name is undefined!
});
}
This was a very common bug before arrow functions. Arrow functions were specifically designed to fix this problem.
4.6 — this Summary Table
| Context | What this refers to |
|---|---|
| Global scope (non-strict) | window (browser) or global (Node.js) |
| Regular function, called directly | window / global (or undefined in strict mode) |
| Method of an object | The object before the dot |
Constructor (called with new) |
The newly created object |
| Event listener (regular function) | The element that fired the event |
| Arrow function | Inherits from the surrounding lexical context |
.call() / .apply() |
Explicitly set (see Chapter 5 & 6) |
.bind() |
Permanently set (see Chapter 7) |
CHAPTER 5 — FUNCTION.CALL()
What Is .call()?
.call() is a method available on every function. It lets you call the function while explicitly setting what this should be inside it.
function.call(thisArg, arg1, arg2, ...)
| Part | Meaning |
|---|---|
thisArg |
The value to use as this inside the function |
arg1, arg2, ... |
Arguments to pass to the function, listed individually |
5.1 — Basic .call() Usage
function introduce() {
console.log("Hi, I'm " + this.name + " and I'm " + this.age);
}
const alice = { name: "Alice", age: 30 };
const bob = { name: "Bob", age: 25 };
introduce.call(alice); // Output: Hi, I'm Alice and I'm 30
introduce.call(bob); // Output: Hi, I'm Bob and I'm 25
The introduce function uses this.name and this.age. By using .call(), we tell it “when you say this, mean alice” (or bob).
Passing arguments with .call():
function greet(greeting, punctuation) {
console.log(greeting + ", " + this.name + punctuation);
}
const person = { name: "Tunde" };
greet.call(person, "Hello", "!"); // Output: Hello, Tunde!
greet.call(person, "Good day", "."); // Output: Good day, Tunde.
Arguments come after the thisArg, separated by commas.
5.2 — Method Borrowing with .call()
One of the most useful applications of .call() is method borrowing — using a method from one object on a different object, without copying it.
const dog = {
name: "Rex",
speak: function(sound) {
console.log(this.name + " says " + sound);
}
};
const cat = { name: "Whiskers" };
// Borrow dog's speak method for cat:
dog.speak.call(cat, "Meow"); // Output: Whiskers says Meow
cat doesn’t have its own speak method, but by using .call() we can temporarily use dog.speak with cat as the context.
5.3 — Using .call() with arguments
A classic use of .call() is converting the arguments object to a real array:
function toArray() {
return Array.prototype.slice.call(arguments);
// Borrow Array's slice method and call it on the arguments object
}
console.log(toArray(1, 2, 3)); // Output: [1, 2, 3]
💡 Note: In modern code, you’d just use
[...arguments]or rest parameters...args. But you’ll see this pattern in older codebases.
5.4 — Constructor Chaining with .call()
.call() is used to run parent constructor logic inside a child constructor:
function Animal(name, sound) {
this.name = name;
this.sound = sound;
}
function Dog(name) {
Animal.call(this, name, "Woof"); // Run Animal's constructor with Dog's 'this'
this.type = "Dog";
}
const d = new Dog("Rex");
console.log(d.name); // Output: Rex
console.log(d.sound); // Output: Woof
console.log(d.type); // Output: Dog
Animal.call(this, ...) says: “Run the Animal constructor, but use the Dog instance as this.” This copies all of Animal’s initialisation into Dog.
CHAPTER 6 — FUNCTION.APPLY()
What Is .apply()?
.apply() works exactly like .call(), with one key difference: instead of passing arguments individually, you pass them as an array.
function.apply(thisArg, [arg1, arg2, ...])
.call() |
.apply() |
|
|---|---|---|
| Arguments | Listed individually | Passed as an array |
| Syntax | fn.call(obj, a, b, c) |
fn.apply(obj, [a, b, c]) |
| When to use | When you know the arguments upfront | When arguments are already in an array |
6.1 — Basic .apply() Usage
function greet(greeting, punctuation) {
console.log(greeting + ", " + this.name + punctuation);
}
const person = { name: "Tunde" };
const args = ["Hello", "!"];
greet.apply(person, args); // Output: Hello, Tunde!
The array args is “spread out” as individual arguments when apply calls the function.
6.2 — Finding Max/Min in an Array Using .apply()
Math.max() normally takes individual numbers: Math.max(1, 2, 3). It doesn’t accept an array. .apply() solves this by spreading an array as individual arguments:
const scores = [85, 92, 78, 96, 88];
const highest = Math.max.apply(null, scores);
const lowest = Math.min.apply(null, scores);
console.log(highest); // Output: 96
console.log(lowest); // Output: 78
null is used as the first argument because Math.max doesn’t use this — we don’t care what this is.
💡 Modern equivalent: The spread operator
...does the same thing more cleanly:Math.max(...scores) // Output: 96But knowing
.apply()is essential for reading older code.
6.3 — Merging Arrays with .apply()
const arrayA = [1, 2, 3];
const arrayB = [4, 5, 6];
Array.prototype.push.apply(arrayA, arrayB);
// Equivalent to: arrayA.push(4, 5, 6)
console.log(arrayA); // Output: [1, 2, 3, 4, 5, 6]
Array.prototype.push doesn’t accept an array directly, but .apply() spreads arrayB into individual push arguments.
💡 Modern equivalent:
arrayA.push(...arrayB)achieves the same thing using the spread operator.
6.4 — .call() vs .apply() — Memory Trick
C for Comma (
.call()takes comma-separated arguments) A for Array (.apply()takes an array)
fn.call(ctx, a, b, c); // C = Comma
fn.apply(ctx, [a, b, c]); // A = Array
CHAPTER 7 — FUNCTION.BIND()
What Is .bind()?
.bind() creates a new function with this permanently locked to the object you specify. Unlike .call() and .apply() which call the function immediately, .bind() returns a new function that you can call later.
const boundFunction = fn.bind(thisArg, arg1, arg2, ...);
.call() |
.apply() |
.bind() |
|
|---|---|---|---|
| Calls the function? | Immediately | Immediately | No — returns a new function |
Sets this? |
Yes | Yes | Yes, permanently |
| Arguments | Individually | As array | Can pre-set (partial application) |
7.1 — Basic .bind() Usage
const person = {
name: "Tunde",
greet: function() {
console.log("Hello, I'm " + this.name);
}
};
const greetTunde = person.greet.bind(person);
// Call it later:
greetTunde(); // Output: Hello, I'm Tunde
greetTunde(); // Output: Hello, I'm Tunde
greetTunde is a new function. No matter how or when you call it, this will always be person.
7.2 — Solving the Lost this Problem
The most common use of .bind() is fixing the “lost this” problem when passing a method as a callback:
const timer = {
seconds: 0,
start: function() {
// ❌ Without bind: 'this' would be undefined or global inside setInterval
setInterval(function() {
this.seconds++; // 'this' is NOT timer here!
console.log(this.seconds);
}, 1000);
}
};
Fix with .bind():
const timer = {
seconds: 0,
start: function() {
setInterval(function() {
this.seconds++;
console.log(this.seconds);
}.bind(this), 1000); // Bind 'this' (which is 'timer' at the point of call)
}
};
timer.start();
// Output (each second): 1, 2, 3, 4, ...
.bind(this) is called while we’re still inside timer.start, where this is timer. The bound function always refers back to timer.
💡 Alternative: Arrow functions also fix this problem without
.bind(). This is why arrow functions are preferred for callbacks inside object methods.
7.3 — Partial Application with .bind()
.bind() can also pre-fill some of a function’s arguments. This is called partial application:
function multiply(a, b) {
return a * b;
}
const double = multiply.bind(null, 2); // Pre-fill 'a' with 2
const triple = multiply.bind(null, 3); // Pre-fill 'a' with 3
console.log(double(5)); // Output: 10 (2 × 5)
console.log(double(10)); // Output: 20 (2 × 10)
console.log(triple(4)); // Output: 12 (3 × 4)
null is the thisArg (we don’t need this here). The 2 pre-fills the first argument a. When we call double(5), it calls multiply(2, 5).
Real-world use — specialising a general function:
function formatCurrency(currencySymbol, amount) {
return currencySymbol + amount.toFixed(2);
}
const formatUSD = formatCurrency.bind(null, "$");
const formatEuros = formatCurrency.bind(null, "€");
const formatGBP = formatCurrency.bind(null, "£");
console.log(formatUSD(19.99)); // Output: $19.99
console.log(formatEuros(24.5)); // Output: €24.50
console.log(formatGBP(9.95)); // Output: £9.95
7.4 — .bind() in Event Listeners with Context
function Button(label) {
this.label = label;
this.clickCount = 0;
}
Button.prototype.handleClick = function() {
this.clickCount++;
console.log(this.label + " clicked " + this.clickCount + " times");
};
const saveBtn = new Button("Save");
const deleteBtn = new Button("Delete");
document.getElementById("save-btn")
.addEventListener("click", saveBtn.handleClick.bind(saveBtn));
document.getElementById("delete-btn")
.addEventListener("click", deleteBtn.handleClick.bind(deleteBtn));
Without .bind(), this inside handleClick would be the DOM button element (not the Button instance), and this.clickCount would be undefined.
CHAPTER 8 — IIFE — IMMEDIATELY INVOKED FUNCTION EXPRESSIONS
What Is an IIFE?
An IIFE (pronounced “iffy”) is a function that is defined and immediately called in one expression. It runs once and disappears.
(function() {
console.log("I run immediately!");
})();
// Output: I run immediately!
Anatomy of an IIFE:
( function() { } ) ()
│ │ │ │ │
│ └── function body ──┘ │ └── call it now
│ │
└─────── wrapping parens ──┘
(turn the declaration into an expression)
The outer parentheses ( ) wrap the function, converting it from a declaration to an expression. The trailing () then immediately calls it.
8.1 — Why IIFEs Exist: The Scope Problem
JavaScript (before ES6) only had function scope — var variables declared inside a function are invisible outside it. But var outside a function was global.
The problem:
var counter = 0; // Global — any script on the page can access and corrupt this
function increment() {
counter++;
}
If two scripts on the same page both define var counter, they overwrite each other. This was a major source of bugs in large applications.
The IIFE solution:
(function() {
var counter = 0; // Trapped inside the IIFE — completely private
function increment() {
counter++;
console.log(counter);
}
increment(); // Output: 1
increment(); // Output: 2
})();
// console.log(counter); // ❌ ReferenceError — counter doesn't exist out here
The IIFE creates a private scope. Variables inside cannot be accessed from outside.
8.2 — IIFEs with Parameters
You can pass arguments to an IIFE:
(function(name, version) {
console.log("Loading " + name + " v" + version);
})("MyApp", "1.0.0");
// Output: Loading MyApp v1.0.0
A very common pattern is passing window or document as arguments, giving them a local alias for performance and minification:
(function(win, doc) {
// Inside here, 'win' refers to the global window object
// 'doc' refers to the document object
win.myApp = {}; // Attach app to global scope
doc.getElementById("app").innerHTML = "Ready";
})(window, document);
8.3 — Arrow Function IIFE
(() => {
console.log("Arrow IIFE!");
})();
// Output: Arrow IIFE!
8.4 — IIFEs Returning Values
An IIFE can return a value, which is immediately captured:
const result = (function() {
const a = 10;
const b = 20;
return a + b;
})();
console.log(result); // Output: 30
8.5 — The Module Pattern (IIFE’s Most Powerful Use)
The module pattern uses an IIFE to create private state and exposes only selected public methods:
const bankAccount = (function() {
// Private — not accessible from outside
let balance = 0;
// Public interface — returned as an object
return {
deposit: function(amount) {
if (amount > 0) balance += amount;
console.log("Deposited £" + amount + ". Balance: £" + balance);
},
withdraw: function(amount) {
if (amount > balance) {
console.log("Insufficient funds.");
return;
}
balance -= amount;
console.log("Withdrew £" + amount + ". Balance: £" + balance);
},
getBalance: function() {
return balance;
}
};
})();
bankAccount.deposit(100); // Output: Deposited £100. Balance: £100
bankAccount.withdraw(30); // Output: Withdrew £30. Balance: £70
console.log(bankAccount.getBalance()); // Output: 70
// console.log(bankAccount.balance); // Output: undefined — 'balance' is private!
balance can never be read or changed directly from outside — only through the methods the module chooses to expose. This is encapsulation, one of the core principles of good software design.
💡 Today’s alternative: ES6 modules (
import/export) handle scope isolation natively. But IIFEs are still used in scripts that run in browsers without a module system, and understanding them is essential for reading legacy code.
🤔 Thinking question: In the bank account example, why can
depositandwithdrawaccessbalanceeven though the IIFE has already returned? (Hint: this is a closure — Chapter 9 explains the mechanism.)
CHAPTER 9 — CLOSURES
What Is a Closure?
A closure is a function that “remembers” the variables from the scope where it was created, even after that scope has finished executing.
This is the concept that makes callbacks, the module pattern, memoisation, and many other patterns work. It is arguably the most important concept in JavaScript.
Simple definition: A closure = a function + the variables it captured from its birth environment.
9.1 — Understanding Scope First
Before closures make sense, you need to understand lexical scoping: in JavaScript, functions can access variables from their outer (enclosing) scopes.
const outerVariable = "I'm outside";
function innerFunction() {
console.log(outerVariable); // Can access the outer variable
}
innerFunction(); // Output: I'm outside
This works because innerFunction was defined inside the same scope as outerVariable. The function has access to its birth environment.
9.2 — The Closure: When the Outer Scope Has Ended
The magic of closures is that the inner function keeps access to the outer variables even after the outer function has finished running and its variables would normally be garbage collected.
function makeCounter() {
let count = 0; // This variable belongs to makeCounter
return function() { // This inner function is returned — it's a closure
count++;
return count;
};
}
const counter = makeCounter(); // makeCounter finishes running here
console.log(counter()); // Output: 1
console.log(counter()); // Output: 2
console.log(counter()); // Output: 3
What is happening:
makeCounter()runs and creates a local variablecount = 0.makeCounter()returns the inner function.makeCounter()finishes — its execution context ends.- BUT:
countis NOT destroyed because the returned function holds a reference to it. - Every time
counter()is called, it accesses and modifies that samecount.
count is enclosed inside the returned function — hence “closure.”
9.3 — Each Closure Is Independent
If you call makeCounter() twice, you get two completely separate closures with their own separate count variables:
function makeCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter1 = makeCounter();
const counter2 = makeCounter();
console.log(counter1()); // Output: 1
console.log(counter1()); // Output: 2
console.log(counter2()); // Output: 1 ← counter2 has its OWN count, starting at 0
console.log(counter1()); // Output: 3 ← counter1 is still going
counter1 and counter2 are completely independent. They each have their own enclosed count.
9.4 — Closures with Parameters
The enclosed value can come from the outer function’s parameters:
function makeAdder(x) {
return function(y) {
return x + y; // x is captured from makeAdder's scope
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(3)); // Output: 8 (5 + 3)
console.log(add5(7)); // Output: 12 (5 + 7)
console.log(add10(3)); // Output: 13 (10 + 3)
add5 permanently remembers x = 5. add10 permanently remembers x = 10. This is partial application through closures.
9.5 — Closures for Private State (Data Encapsulation)
Closures are the mechanism that makes the IIFE module pattern work. They also allow you to create private variables without using an IIFE:
function createPerson(name) {
// 'name' is private — accessible only through the returned methods
let _name = name;
let _callCount = 0;
return {
getName: function() {
_callCount++;
return _name;
},
setName: function(newName) {
if (typeof newName === "string" && newName.trim() !== "") {
_name = newName;
}
},
getCallCount: function() {
return _callCount;
}
};
}
const person = createPerson("Alice");
console.log(person.getName()); // Output: Alice
console.log(person.getName()); // Output: Alice
console.log(person.getCallCount()); // Output: 2
person.setName("Bob");
console.log(person.getName()); // Output: Bob
// console.log(person._name); // Output: undefined — truly private
_name and _callCount can never be accessed or modified directly from outside. They are private to the closure.
9.6 — The Classic Closure Loop Bug
This is one of the most famous JavaScript gotchas:
// ❌ Bug: All buttons alert "3"
for (var i = 0; i < 3; i++) {
const btn = document.createElement("button");
btn.innerText = "Button " + i;
btn.addEventListener("click", function() {
alert("I am button " + i); // By the time this runs, i is 3 (the loop ended)
});
document.body.appendChild(btn);
}
// Clicking any button → Alert: "I am button 3"
Why: var is function-scoped, so all three callbacks share the same i variable. By the time any button is clicked, the loop has finished and i is 3.
Fix 1 — Use let (block-scoped, creates a new i for each iteration):
for (let i = 0; i < 3; i++) { // 'let' creates a new i per iteration
const btn = document.createElement("button");
btn.innerText = "Button " + i;
btn.addEventListener("click", function() {
alert("I am button " + i); // Each closure now has its own 'i'
});
document.body.appendChild(btn);
}
// Clicking Button 0 → Alert: "I am button 0"
// Clicking Button 1 → Alert: "I am button 1"
// Clicking Button 2 → Alert: "I am button 2"
Fix 2 — Use an IIFE to create a new scope per iteration (pre-ES6 approach):
for (var i = 0; i < 3; i++) {
(function(j) { // j is a new variable per iteration, capturing current i
const btn = document.createElement("button");
btn.innerText = "Button " + j;
btn.addEventListener("click", function() {
alert("I am button " + j);
});
document.body.appendChild(btn);
})(i);
}
⚠️ This bug is extremely common in interviews. Make sure you understand both why it happens and how to fix it.
9.7 — Closures in Real-World Code
React hooks are closures:
// useState in React returns a pair where the setter closes over the state
const [count, setCount] = useState(0);
// Every time setCount is called, it updates the closed-over 'count'
Event handlers with context:
function setupHandler(userId) {
document.getElementById("profile-btn").addEventListener("click", function() {
fetchUserProfile(userId); // userId is captured — no need to pass it explicitly
});
}
setupHandler(42); // The handler will always fetch user 42
setTimeout inside loops:
// Send a message after 1, 2, and 3 seconds
[1, 2, 3].forEach(function(seconds) {
setTimeout(function() {
console.log("Message after " + seconds + " seconds");
// 'seconds' is captured correctly because forEach callback creates a new scope
}, seconds * 1000);
});
CHAPTER 10 — FUNCTION REFERENCE (BUILT-IN METHODS)
Overview
JavaScript provides several built-in methods on every function object. Understanding these turns you from a passive function user into someone who can control exactly how functions are called.
10.1 — Function.prototype.toString()
Returns the source code of the function as a string:
function add(a, b) {
return a + b;
}
console.log(add.toString());
// Output:
// function add(a, b) {
// return a + b;
// }
Used for debugging, code analysis tools, and documentation generators.
10.2 — Function.prototype.length
The length property returns the number of expected parameters (not counting rest parameters or parameters with defaults):
function f1(a, b, c) {}
function f2(a, b = 0, c) {}
function f3(a, ...rest) {}
console.log(f1.length); // Output: 3
console.log(f2.length); // Output: 1 (only 'a' counts — 'b' has a default)
console.log(f3.length); // Output: 1 (only 'a' counts — rest doesn't count)
This is used by libraries to check how many arguments a function expects and dispatch accordingly.
10.3 — Function.prototype.name
Returns the name of the function:
function greet() {}
const sayHi = function hello() {};
const arrow = () => {};
const obj = { method() {} };
console.log(greet.name); // Output: greet
console.log(sayHi.name); // Output: hello
console.log(arrow.name); // Output: arrow
console.log(obj.method.name); // Output: method
Useful in debugging — error stack traces use the function name to show where a problem occurred. Anonymous functions appear as "" or "anonymous" in stack traces, making debugging harder.
10.4 — Function.prototype.call() — Recap
Already covered in Chapter 5. Quick reference:
fn.call(thisArg, arg1, arg2, ...);
// Calls fn immediately, setting 'this' to thisArg
10.5 — Function.prototype.apply() — Recap
Already covered in Chapter 6. Quick reference:
fn.apply(thisArg, [arg1, arg2, ...]);
// Calls fn immediately, passing args as an array
10.6 — Function.prototype.bind() — Recap
Already covered in Chapter 7. Quick reference:
const boundFn = fn.bind(thisArg, arg1, arg2, ...);
// Returns a new function with 'this' and optional args pre-set
10.7 — Quick Reference Table
| Method / Property | Type | Purpose |
|---|---|---|
.call(thisArg, ...args) |
Method | Call immediately with explicit this, individual args |
.apply(thisArg, [args]) |
Method | Call immediately with explicit this, array of args |
.bind(thisArg, ...args) |
Method | Return new function with this and args pre-set |
.toString() |
Method | Return function source code as a string |
.length |
Property | Number of declared parameters (excluding defaults and rest) |
.name |
Property | Name of the function |
PHASE 2 — APPLIED EXERCISES
Exercise 1 — Function Definition Mastery
Objective: Practice writing the same logic using four different function definition styles.
Scenario: A temperature conversion utility for a weather app.
Warm-up mini-example:
// Celsius to Fahrenheit: F = (C × 9/5) + 32
const toF = c => (c * 9/5) + 32;
console.log(toF(0)); // Output: 32
console.log(toF(100)); // Output: 212
Step-by-step instructions:
Write a celsiusToFahrenheit function using:
- A function declaration
- A function expression (anonymous)
- A named function expression
- An arrow function (concise single-line)
Then write a convertTemperatures(temps, converterFn) function that accepts an array of temperatures and a converter function, returning a new array of converted values. Call it with all four converter styles.
Expected output:
convertTemperatures([0, 20, 37, 100], celsiusToFahrenheit);
// Output: [32, 68, 98.6, 212]
Self-check questions:
- Which of the four styles is hoisted? Test it by calling the function before its definition.
- In
convertTemperatures, is the converter parameter a callback? How do you know?
Exercise 2 — Higher-Order Function Practice
Objective: Build a mini utility library of higher-order functions.
Scenario: You’re building a data processing utility for a school’s grade tracking system.
Step-by-step instructions:
Start with this data:
const students = [
{ name: "Alice", grade: 92 },
{ name: "Bob", grade: 55 },
{ name: "Carol", grade: 78 },
{ name: "David", grade: 61 },
{ name: "Eve", grade: 88 },
];
Write the following functions using .map(), .filter(), and .reduce() (all higher-order functions):
getNames(students)→ Returns["Alice", "Bob", "Carol", "David", "Eve"]getPassingStudents(students)→ Returns students with grade ≥ 65getAverageGrade(students)→ Returns the average grade (use.reduce())getLetterGrade(score)→ Returns “A” (≥90), “B” (≥80), “C” (≥70), “D” (≥60), “F” (<60)gradeReport(students)→ Returns a string array:["Alice: A", "Bob: F", ...]
Warm-up mini-example for reduce:
const total = [10, 20, 30].reduce((accumulator, current) => accumulator + current, 0);
console.log(total); // Output: 60
Self-check questions:
- Is
.map()a higher-order function? Why? - In
gradeReport, you’ll composegetLetterGradeinside a.map()— what does that make the outer function?
Exercise 3 — Callback Chain
Objective: Simulate an asynchronous login flow using callbacks.
Scenario: A login system with three steps: validate credentials, fetch user profile, load user settings. Each step takes a simulated delay using setTimeout.
Step-by-step instructions:
function validateCredentials(username, password, callback) {
setTimeout(function() {
const isValid = username === "admin" && password === "1234";
if (isValid) {
callback(null, { userId: 42, username });
} else {
callback(new Error("Invalid credentials"));
}
}, 500);
}
Write similar functions for fetchUserProfile(userId, callback) and loadUserSettings(userId, callback). Chain them together using error-first callbacks. If any step fails, stop and log the error.
Hints:
- Check
if (err) { ... return; }at the start of each callback - The chain should look like: validate → if OK → fetch profile → if OK → load settings → display welcome message
Self-check questions:
- What is the maximum nesting depth if you chain 5 callbacks?
- How would you refactor this using named functions to flatten the nesting?
Exercise 4 — Understanding this
Objective: Predict the value of this in different contexts before running the code.
Step-by-step instructions:
For each snippet below, write your prediction of what will be logged before you run it:
// Snippet A
const obj = {
value: 42,
getValue: function() {
return this.value;
}
};
const fn = obj.getValue;
console.log(fn()); // Prediction: ___?
console.log(obj.getValue()); // Prediction: ___?
// Snippet B
function Timer() {
this.ticks = 0;
setTimeout(function() {
this.ticks++;
console.log(this.ticks); // Prediction: ___?
}, 100);
}
new Timer();
// Snippet C
function Timer() {
this.ticks = 0;
setTimeout(() => {
this.ticks++;
console.log(this.ticks); // Prediction: ___?
}, 100);
}
new Timer();
After predicting, run each snippet and compare. If wrong, write an explanation of why.
Exercise 5 — call, apply, bind in Action
Objective: Solve three real-world problems using the correct method.
Problem 1 (use .call()):
You have a fullName function and two objects. Call it for each without modifying the objects.
function fullName(separator) {
return this.first + separator + this.last;
}
const person1 = { first: "Ada", last: "Lovelace" };
const person2 = { first: "Grace", last: "Hopper" };
// Expected:
// Ada Lovelace
// Grace-Hopper
Problem 2 (use .apply()):
Find the maximum and minimum values from this array using Math.max and Math.min with .apply():
const temperatures = [23, 17, 31, 8, 27, 15, 29];
// Expected: Max: 31, Min: 8
Problem 3 (use .bind()):
Fix the broken countdown so it logs correctly:
function Counter(start) {
this.count = start;
this.tick = function() {
if (this.count > 0) {
console.log(this.count--);
setTimeout(this.tick, 500); // ❌ 'this' will be lost here
}
};
this.tick();
}
new Counter(5);
Exercise 6 — IIFE for Private Configuration
Objective: Use an IIFE to create a private configuration module.
Scenario: You’re building a web app that needs configuration settings (API base URL, default page size, app version) that should be read-only — nothing outside should be able to change them after initialisation.
Step-by-step instructions:
const config = (function() {
const _settings = {
apiBase: "https://api.myapp.com/v1",
pageSize: 20,
version: "2.3.1",
debug: false
};
return {
get: function(key) { /* return the value for key */ },
getAll: function() { /* return a copy, not the original */ },
isDebug: function() { /* return the debug flag */ }
};
})();
Fill in the three method bodies. getAll must return a copy of _settings so external code can’t modify the original. Hint: use Object.assign({}, _settings) or the spread operator {..._settings}.
Expected:
console.log(config.get("version")); // Output: 2.3.1
console.log(config.isDebug()); // Output: false
const copy = config.getAll();
copy.version = "hack"; // Modifying the copy
console.log(config.get("version")); // Output: 2.3.1 ← original unchanged
Exercise 7 — Closure Mastery
Objective: Build a reusable rate limiter using closures.
Scenario: A button on a web page that should only be clickable once every 3 seconds (to prevent spam clicks). Use a closure to remember the last time the button was clicked.
Step-by-step instructions:
function createRateLimiter(fn, cooldownMs) {
let lastCalled = 0;
return function() {
const now = Date.now();
if (now - lastCalled < cooldownMs) {
console.log("Too soon! Please wait.");
return;
}
lastCalled = now;
fn();
};
}
function saveData() {
console.log("Data saved at " + new Date().toLocaleTimeString());
}
const limitedSave = createRateLimiter(saveData, 3000);
// Simulating rapid clicks:
limitedSave(); // Output: Data saved at ...
limitedSave(); // Output: Too soon! Please wait.
limitedSave(); // Output: Too soon! Please wait.
// (3 seconds later)
limitedSave(); // Output: Data saved at ...
Self-check questions:
- Which variable in
createRateLimiteris closed over by the returned function? - What would happen if
lastCalledwere declared withvarinside the returned function instead of in the outer function? - How does this differ from just using a global
lastCalledvariable?
PHASE 3 — PROJECT SIMULATION
Project: Functional Task Pipeline with Private State
Scenario: You are a junior developer at a project management startup. Your task is to build a lightweight JavaScript pipeline that processes tasks through several stages — filtering, transforming, prioritising — using functional programming concepts. All internal state must be private (no global variables), and the system must be extensible.
This project deliberately combines all ten chapters:
- Function definitions (Chapter 1)
- Higher-order functions and composition (Chapter 2)
- Callbacks (Chapter 3)
- Closures for private state (Chapter 9)
- IIFE for module encapsulation (Chapter 8)
.call()/.apply()/.bind()where appropriate (Chapters 5–7)
Stage 1 — Data and Core Utilities
Setup: The task data and basic utility functions
// Raw task data — simulating a real project's task list
const rawTasks = [
{ id: 1, title: "Write unit tests", category: "dev", hours: 3, done: false },
{ id: 2, title: "Design login screen", category: "design", hours: 5, done: false },
{ id: 3, title: "Fix payment bug", category: "dev", hours: 1, done: true },
{ id: 4, title: "Update documentation", category: "writing", hours: 2, done: false },
{ id: 5, title: "Code review", category: "dev", hours: 2, done: false },
{ id: 6, title: "Client presentation", category: "meeting", hours: 4, done: true },
{ id: 7, title: "Database optimisation", category: "dev", hours: 6, done: false },
{ id: 8, title: "Write blog post", category: "writing", hours: 3, done: false },
];
Core utility functions (pure functions only):
// Filter tasks by a predicate function
function filterTasks(tasks, predicate) {
return tasks.filter(predicate);
}
// Transform tasks (map)
function transformTasks(tasks, transformer) {
return tasks.map(transformer);
}
// Reduce tasks to a single value
function reduceTasks(tasks, reducer, initialValue) {
return tasks.reduce(reducer, initialValue);
}
// Sort tasks by a key
function sortTasks(tasks, keyFn, ascending = true) {
return [...tasks].sort((a, b) => {
const valA = keyFn(a);
const valB = keyFn(b);
return ascending ? (valA > valB ? 1 : -1) : (valA < valB ? 1 : -1);
});
}
Expected output — test Stage 1:
const pendingTasks = filterTasks(rawTasks, task => !task.done);
console.log(pendingTasks.length); // Output: 6
const totalHours = reduceTasks(rawTasks, (sum, t) => sum + t.hours, 0);
console.log(totalHours); // Output: 26
Stage 2 — The Pipeline Builder (Higher-Order Functions + Closures)
Build a reusable pipeline function that chains operations together:
function createPipeline(...steps) {
// Each 'step' is a function that takes tasks and returns modified tasks
return function(tasks) {
return steps.reduce(function(currentTasks, step) {
return step(currentTasks);
}, tasks);
};
}
Create specialised step functions using partial application via .bind():
// Step factories — each returns a function for use in the pipeline
const onlyPending = tasks => filterTasks(tasks, t => !t.done);
const onlyDev = tasks => filterTasks(tasks, t => t.category === "dev");
const byHoursAsc = tasks => sortTasks(tasks, t => t.hours, true);
const byHoursDesc = tasks => sortTasks(tasks, t => t.hours, false);
// Generalised filter step factory using closure:
function byCategoryStep(category) {
return function(tasks) {
return filterTasks(tasks, t => t.category === category);
};
}
const addPriorityLabel = tasks => transformTasks(tasks, function(task) {
const priority = task.hours >= 5 ? "HIGH"
: task.hours >= 3 ? "MEDIUM"
: "LOW";
return { ...task, priority };
});
Compose pipelines:
const devBacklogPipeline = createPipeline(
onlyPending,
onlyDev,
addPriorityLabel,
byHoursDesc
);
const results = devBacklogPipeline(rawTasks);
results.forEach(t => {
console.log(`[${t.priority}] ${t.title} (${t.hours}h)`);
});
// Expected output:
// [HIGH] Database optimisation (6h)
// [MEDIUM] Write unit tests (3h)
// [LOW] Fix payment bug (1h) ← wait, this is 'done:true' — it should be filtered out
// Actually: only pending dev tasks:
// [HIGH] Database optimisation (6h)
// [MEDIUM] Write unit tests (3h)
// [LOW] Code review (2h)
Stage 3 — The Private Task Manager Module (IIFE + Closures)
Encapsulate everything in a module with private state and a public API:
const TaskManager = (function() {
// Private state
let _tasks = [];
let _history = [];
let _nextId = 1;
// Private helpers
function _log(action, taskId) {
_history.push({
action,
taskId,
timestamp: new Date().toISOString()
});
}
// Public API
return {
loadTasks: function(tasks) {
_tasks = tasks.map(t => ({ ...t })); // Store a copy, not the original
_nextId = Math.max(..._tasks.map(t => t.id)) + 1;
_log("LOAD", null);
},
addTask: function(title, category, hours) {
const task = { id: _nextId++, title, category, hours, done: false };
_tasks.push(task);
_log("ADD", task.id);
return task;
},
completeTask: function(id) {
const task = _tasks.find(t => t.id === id);
if (task) {
task.done = true;
_log("COMPLETE", id);
return true;
}
return false;
},
runPipeline: function(pipeline) {
return pipeline([..._tasks]); // Pass a copy to the pipeline
},
getStats: function() {
const total = _tasks.length;
const done = _tasks.filter(t => t.done).length;
const pending = total - done;
const totalHrs = reduceTasks(_tasks, (s, t) => s + t.hours, 0);
const doneHrs = reduceTasks(_tasks.filter(t => t.done), (s, t) => s + t.hours, 0);
return { total, done, pending, totalHrs, doneHrs };
},
getHistory: function() {
return [..._history]; // Return a copy
}
};
})();
Using the module:
TaskManager.loadTasks(rawTasks);
// Add a new task
TaskManager.addTask("Performance audit", "dev", 4);
// Complete a task
TaskManager.completeTask(4);
// Run a pipeline through the manager
const devPipeline = createPipeline(onlyPending, onlyDev, addPriorityLabel, byHoursDesc);
const devResults = TaskManager.runPipeline(devPipeline);
console.log("Dev backlog:");
devResults.forEach(t => console.log(` [${t.priority}] ${t.title}`));
// Stats
const stats = TaskManager.getStats();
console.log("\nProject Stats:");
console.log(" Total tasks: " + stats.total);
console.log(" Done: " + stats.done);
console.log(" Pending: " + stats.pending);
console.log(" Total hours: " + stats.totalHrs);
// History
console.log("\nAudit Trail:");
TaskManager.getHistory().forEach(entry => {
console.log(" " + entry.action + (entry.taskId ? " #" + entry.taskId : "") + " at " + entry.timestamp);
});
Expected output:
Dev backlog:
[HIGH] Database optimisation
[HIGH] Performance audit
[MEDIUM] Write unit tests
[LOW] Code review
Project Stats:
Total tasks: 9
Done: 3
Pending: 6
Total hours: 30
Audit Trail:
LOAD at 2024-...
ADD #9 at 2024-...
COMPLETE #4 at 2024-...
Stage 4 — Advanced Challenge: Debounce Utility (Closures + Timers)
The debounce function is used everywhere in real applications — search boxes, resize handlers, form auto-saves. It ensures a function doesn’t fire more than once within a set time window, no matter how many times it’s triggered.
function debounce(fn, delayMs) {
let timeoutId = null; // Closed-over variable — persists between calls
return function(...args) {
// If the timer is already running, cancel it and restart
clearTimeout(timeoutId);
// Start a new timer
timeoutId = setTimeout(() => {
fn.apply(this, args); // Use .apply() to preserve 'this' and pass args
timeoutId = null;
}, delayMs);
};
}
Usage — search box that only queries after typing stops:
function searchTasks(query) {
const results = TaskManager.runPipeline(
createPipeline(tasks => tasks.filter(t => t.title.toLowerCase().includes(query)))
);
console.log("Search '" + query + "':", results.map(t => t.title));
}
const debouncedSearch = debounce(searchTasks, 300);
// Simulate fast keystrokes:
debouncedSearch("w");
debouncedSearch("wr");
debouncedSearch("wri");
debouncedSearch("writ");
// Only "writ" search will actually run (the others were cancelled)
// Output: Search 'writ': ["Write unit tests", "Write blog post"]
Why this uses closures: timeoutId is captured by the returned function. Every call to debouncedSearch reads and updates the same timeoutId. Without a closure, this would require a global variable.
Reflection Questions:
- In
TaskManager, why doesrunPipelinepass[..._tasks](a copy) to the pipeline instead of_tasksdirectly? - In
debounce, the function usesfn.apply(this, args). What would happen if you just usedfn(...args)instead? When would they differ? - The
_historyarray inTaskManagercan grow indefinitely. In a real application, how would you handle this? (Think: max length, persisting tolocalStorage, sending to a server.) - How would you modify
createPipelineto support async pipeline steps (steps that return a Promise)? - The
devBacklogPipelineis created outsideTaskManager. What would be the advantages and disadvantages of moving pipeline creation inside the module?
QUIZ & COMPLETION CHECKLIST
Self-Assessment Quiz
Test your understanding. Write your answers before checking.
Q1: What is the difference between a function declaration and a function expression in terms of hoisting?
Q2: Write a concise arrow function that takes a number n and returns its cube (n³).
Q3: What does a rest parameter (...args) return — an array or an object?
Q4: In the following code, what is logged and why?
const obj = { n: 5 };
function double() { return this.n * 2; }
console.log(double.call(obj)); // ?
Q5: What is the key difference between .call() and .apply()?
Q6: What does .bind() return — a value or a function?
Q7: What does event.preventDefault() do and why is it needed in form validation?
Q8: What is an IIFE and give one reason to use it?
Q9: Given the following code, what are the two outputs and why are they different?
function makeCounter() {
let count = 0;
return () => ++count;
}
const a = makeCounter();
const b = makeCounter();
a(); a();
console.log(a()); // ?
console.log(b()); // ?
Q10: In the loop closure bug, why does using let instead of var fix the problem?
Answer Key
A1: Function declarations are hoisted — you can call them before they appear. Function expressions are not — accessing them before assignment throws a ReferenceError (for let/const) or returns undefined then throws (for var).
A2: const cube = n => n ** 3; (or n * n * n)
A3: A real array, with all standard array methods available.
A4: 10. .call(obj) sets this to obj, so this.n is 5. 5 * 2 = 10.
A5: .call() takes arguments individually: fn.call(ctx, a, b, c). .apply() takes them as an array: fn.apply(ctx, [a, b, c]).
A6: A new function — with this and any pre-filled arguments permanently set.
A7: It stops the browser’s default form submission behaviour (page reload), allowing JavaScript to handle validation and submission instead.
A8: A function defined and immediately executed: (function() { ... })(). Use cases: creating a private scope to avoid polluting global variables, or running initialisation code that shouldn’t be repeatable.
A9: a() logs 3 (called three times: 1, 2, 3). b() logs 1 (independent closure, count starts fresh at 0). Each call to makeCounter() creates a separate count variable.
A10: var is function-scoped — all loop iterations share one var i variable. By the time callbacks run, the loop has ended and i is the final value. let is block-scoped — each iteration creates a brand-new i binding. Each callback closes over its own separate i.
Completion Checklist
| # | Requirement | ✓ |
|---|---|---|
| 1 | Can write functions using all four definition styles | ✓ |
| 2 | Understand hoisting and how it affects declarations vs expressions | ✓ |
| 3 | Can use default parameters, rest parameters, and the arguments object | ✓ |
| 4 | Understand higher-order functions, callbacks, and function composition | ✓ |
| 5 | Can explain what this refers to in six different contexts |
✓ |
| 6 | Can use .call(), .apply(), and .bind() for their correct use cases |
✓ |
| 7 | Can write and explain the purpose of an IIFE | ✓ |
| 8 | Can explain what a closure is and why it works | ✓ |
| 9 | Can identify and fix the classic closure loop bug | ✓ |
| 10 | Know the built-in function properties: .length, .name, .toString() |
✓ |
| 11 | Built the complete Task Pipeline project combining all concepts | ✓ |
| 12 | All beginner gotchas highlighted and explained | ✓ |
Key Gotchas Summary
| Mistake | Why It Happens | Fix |
|---|---|---|
| Calling a function expression before assignment | Not hoisted | Move the expression up, or use a declaration |
addEventListener("click", fn()) |
Calls fn immediately; passes its return value |
Use fn without parentheses |
this is wrong inside a callback |
Plain function call loses object context | Use .bind() or an arrow function |
const for a counter variable |
const can’t be reassigned |
Use let |
Loop closures all share one var |
var is function-scoped, not block-scoped |
Use let |
| Can’t remove an anonymous listener | No reference to pass to removeEventListener |
Use a named function |
Default values trigger for null |
They only trigger for undefined, not null |
Check explicitly if needed |
| Arrow functions in object methods | No own this — inherits from outer scope |
Use regular function for methods |
One-Sentence Summary
JavaScript functions are first-class citizens that can be defined in multiple ways, passed as callbacks, composed into pipelines, scoped with IIFEs, bound to specific contexts with
call/apply/bind, and made to “remember” private variables through closures — making them the single most powerful tool in the entire language.
Tutorial generated by AI_TUTORIAL_GENERATOR · Source curriculum: W3Schools JavaScript Functions (11 pages)