🧩 Functions and Scope
A function is a named recipe: give it ingredients (inputs), it runs the steps (logic), and hands back a dish (a return value). Functions are how you stop copy-pasting the same code and start building programs out of reusable, testable, well-named pieces. In this lesson you'll meet every way to write one — and learn the rules that decide which variables a function can see.
Week 1 · Day 5 (Friday: Introduction to JavaScript) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Write functions three ways — declarations, expressions, and arrow functions — and pick the right one
- Explain hoisting and the difference between a declaration and an expression
- Use default, rest, and destructured parameters to write flexible signatures
- Trace the scope chain across global, function, and block scope
- Explain closures and use them to create private state
- Write higher-order functions and simple recursion with a base case
Estimated Time: 80 minutes
Practice: Build a closure-based counter and a small pure-function utility library.
In This Lesson
Why Functions?
Without functions, a large program is one long, tangled script where changing one thing risks breaking ten others. Functions solve that by letting you name a chunk of behavior once and call it by that name everywhere — the essence of DRY (Don't Repeat Yourself). Feed a value in, get a result out.
arguments] --> B[Function
parameters + logic] B --> C[Output
return value]
A good function is like a good appliance: you use it through a simple interface (its name and parameters) without caring about the wiring inside. That's why functions give you:
- Reusability — write the logic once, call it a hundred times
- Modularity — break a big problem into small, solvable pieces
- Abstraction — hide messy details behind a clear name
- Testability — verify each piece in isolation
Three Ways to Write a Function
1. Function declaration
The classic form. A declaration is hoisted — the whole function is available even before the line it's written on.
function greet(name) {
return `Hello, ${name}!`;
}
console.log(greet("Alice")); // "Hello, Alice!"
// Multiple parameters, and an early return
function isPositive(number) {
if (number > 0) return true;
return false; // could also be: return number > 0;
}
// A function with no explicit return gives back undefined
function logMessage(message) {
console.log(message); // returns undefined implicitly
}
2. Function expression
Here the function is a value assigned to a variable. Expressions are not hoisted like declarations — you can only call them after the assignment runs.
const greet = function (name) {
return `Hello, ${name}!`;
};
// A named function expression can call itself by that internal name
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1);
};
// IIFE — an expression that runs itself immediately (creates a private scope)
(function () {
console.log("This runs right away!");
})();
📖 Declaration vs expression & hoisting
A declaration is hoisted whole, so greet() works above its definition. An expression assigned to const/let is not usable until that line executes (calling it earlier throws). Prefer declarations for top-level named helpers and expressions when passing a function as a value.
Arrow Functions
Arrow functions (ES6) are a shorter syntax for function expressions, with one important twist: they don't have their own this. That single difference makes them the default choice for callbacks.
// Full form
const greet = (name) => {
return `Hello, ${name}!`;
};
// Concise form — a single expression is returned implicitly (no braces, no return)
const greetShort = name => `Hello, ${name}!`;
const add = (a, b) => a + b;
const sayHello = () => "Hello!"; // no params still needs ()
// The reason arrows shine as callbacks: array methods
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2); // [2, 4, 6, 8, 10]
const evens = numbers.filter(n => n % 2 === 0); // [2, 4]
const sum = numbers.reduce((acc, n) => acc + n, 0); // 15
💡 Lexical this
An arrow function borrows this from the scope where it was defined, not where it's called. That's exactly what you want inside a method's callback:
const person = {
name: "Alice",
hobbies: ["reading", "coding"],
showHobbies() {
// Arrow keeps `this` = person; a regular function here would lose it
this.hobbies.forEach(hobby => {
console.log(`${this.name} likes ${hobby}`);
});
}
};
person.showHobbies(); // Alice likes reading / Alice likes coding
Because arrows have no own this, don't use them as object methods that need this, or as constructors.
Parameters
Modern JavaScript gives parameters superpowers: sensible defaults, gathering "the rest" into an array, and pulling fields straight out of an object.
Default parameters
function greet(name = "Guest") {
return `Hello, ${name}!`;
}
console.log(greet()); // "Hello, Guest!"
console.log(greet("Alice")); // "Hello, Alice!"
// Defaults can use expressions and earlier parameters
function createUser(name, role = "user", id = Date.now()) {
return { name, role, id };
}
function createPoint(x = 0, y = x) { // y defaults to whatever x is
return { x, y };
}
Rest parameters — gather many args into an array
function sum(...numbers) { // numbers is a real array
return numbers.reduce((acc, n) => acc + n, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
function introduce(greeting, ...names) {
return `${greeting} ${names.join(", ")}!`;
}
console.log(introduce("Hello", "Alice", "Bob", "Charlie"));
// "Hello Alice, Bob, Charlie!"
Destructuring parameters
// Pull named fields out of an object argument — self-documenting call sites
function createUser({ name, age, email }) {
return { name, age, email, created: new Date() };
}
createUser({ name: "Alice", age: 30, email: "alice@example.com" });
// Array destructuring with a default
function getCoordinates([x, y, z = 0]) {
return { x, y, z };
}
console.log(getCoordinates([10, 20])); // { x: 10, y: 20, z: 0 }
Scope
Scope answers one question: from any given line, which variables can I see? JavaScript nests scopes like Russian dolls. Inner code can look outward to enclosing scopes, but outer code can't peek inward.
visible everywhere] --> B[Function Scope
only inside the function] B --> C[Block Scope
only inside these braces] C -. can read outward .-> B B -. can read outward .-> A
const globalConst = "I'm global"; // visible everywhere
function outer() {
const functionScoped = "only inside outer()";
if (true) {
const blockScoped = "only inside these braces";
console.log(globalConst); // ✅ reaches out to global
console.log(functionScoped); // ✅ reaches out to the function
console.log(blockScoped); // ✅ same block
}
// console.log(blockScoped); // ❌ ReferenceError — it's gone
}
// console.log(functionScoped); // ❌ ReferenceError
⚠️ let/const are block-scoped; var is not
// let is scoped to each loop iteration — the classic closures-in-a-loop fix
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0, 1, 2 ✅
}
// var leaks: there's only ONE j, and by the time the callbacks run it's 3
for (var j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0); // 3, 3, 3 ❌
}
This is one of the most-quoted JavaScript gotchas — and it evaporates the moment you use let instead of var.
Closures
A closure is a function bundled together with the variables it captured from the scope where it was created. In plain terms: an inner function remembers the outer variables it used, even after the outer function has finished running. This is how JavaScript creates private, persistent state.
function createCounter() {
let count = 0; // private — no one outside can touch it
return function () {
count++; // the returned function "closes over" count
return count;
};
}
const counter1 = createCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2
const counter2 = createCounter();
console.log(counter2()); // 1 ← a fresh, independent count
The two counters don't interfere because each call to createCounter makes a new count. Closures give you real encapsulation — a "private variable" the outside world can only affect through the methods you expose:
function createBankAccount(initialBalance) {
let balance = initialBalance; // private state
return {
deposit(amount) {
if (amount > 0) balance += amount;
return balance;
},
withdraw(amount) {
if (amount > 0 && amount <= balance) balance -= amount;
return balance;
},
getBalance() { return balance; }
};
}
const account = createBankAccount(100);
console.log(account.deposit(50)); // 150
console.log(account.withdraw(30)); // 120
console.log(account.balance); // undefined — balance is private!
Higher-Order Functions
A higher-order function does one of two things: it takes a function as an argument, returns a function, or both. You've already used them — map, filter, and reduce all accept a function. Writing your own unlocks powerful, composable code.
// Returns a function — a "factory" that bakes in configuration
function createMultiplier(factor) {
return (number) => number * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// Takes a function — runs it a given number of times
function repeat(fn, times) {
for (let i = 0; i < times; i++) fn(i);
}
repeat(i => console.log(`Call #${i}`), 3);
// Compose two functions into one
const compose = (f, g) => (x) => f(g(x));
const addOne = x => x + 1;
const timesTwo = x => x * 2;
const timesTwoThenAddOne = compose(addOne, timesTwo);
console.log(timesTwoThenAddOne(5)); // (5 * 2) + 1 = 11
✅ Why this matters
Higher-order functions let you treat behavior as data — passing it around, storing it, and building small pieces into bigger ones. Nearly all of React, and most modern array work, is higher-order functions in action.
Recursion
A recursive function calls itself to solve a smaller version of the same problem. Every recursion needs two things: a base case that stops the calls, and a recursive case that moves toward it. Miss the base case and you get infinite recursion (a stack overflow).
// factorial: 5! = 5 * 4 * 3 * 2 * 1
function factorial(n) {
if (n <= 1) return 1; // base case — stop here
return n * factorial(n - 1); // recursive case — smaller problem
}
console.log(factorial(5)); // 120
// Recursion naturally fits nested structures — sum a binary tree
function sumTree(node) {
if (!node) return 0; // base case: empty branch
return node.value + sumTree(node.left) + sumTree(node.right);
}
// Naive fibonacci is elegant but slow; memoizing caches results
function makeFib() {
const cache = new Map();
return function fib(n) {
if (n <= 1) return n;
if (cache.has(n)) return cache.get(n);
const result = fib(n - 1) + fib(n - 2);
cache.set(n, result);
return result;
};
}
const fib = makeFib();
console.log(fib(10)); // 55
Recursion and iteration can solve many of the same problems. Reach for recursion when the data itself is nested (trees, folders, nested arrays); reach for loops when you're marching through a flat sequence.
Practice & Quiz
🏋️ Exercise 1: A closure-based counter with reset
Goal: Write makeCounter(start) that returns an object with increment(), decrement(), and reset(). The current value must be private.
function makeCounter(start = 0) {
// TODO: keep a private count; return methods that read/change it
}
const c = makeCounter(10);
console.log(c.increment()); // 11
console.log(c.decrement()); // 10
console.log(c.reset()); // 10 (back to start)
💡 Hint
Capture both start and a mutable count in the closure. Each returned method changes count and returns it. reset() sets count = start.
✅ Solution
function makeCounter(start = 0) {
let count = start;
return {
increment() { count += 1; return count; },
decrement() { count -= 1; return count; },
reset() { count = start; return count; },
value() { return count; }
};
}
const c = makeCounter(10);
console.log(c.increment()); // 11
console.log(c.decrement()); // 10
console.log(c.reset()); // 10
🏋️ Exercise 2: Pure utility functions
Goal: Write three pure functions (no side effects, output depends only on input): capitalize(str), unique(arr), and chunk(arr, size).
✅ Solution
const capitalize = (str) =>
str.charAt(0).toUpperCase() + str.slice(1);
const unique = (arr) => [...new Set(arr)];
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
console.log(capitalize("hello")); // "Hello"
console.log(unique([1, 1, 2, 3, 3])); // [1, 2, 3]
console.log(chunk([1, 2, 3, 4, 5], 2)); // [[1,2],[3,4],[5]]
🎯 Quick Quiz
Question 1: What's the key behavioral difference between an arrow function and a regular function?
Question 2: A closure lets an inner function...
Question 3: Every recursive function must have a...
Best Practices & Pitfalls
✅ Do
- Give functions verb-based, descriptive names:
calculateTotal,isValid - Keep functions small and single-purpose — one job each
- Prefer pure functions (same input → same output, no side effects) when you can
- Use arrow functions for callbacks; use methods/declarations when you need
this - Limit parameters (≈3); pass an options object when you need more
❌ Don't
- Create implicit globals by assigning to an undeclared variable
- Use an arrow function as an object method that relies on
this - Write recursion without a reachable base case
- Mutate external state from inside a function unless that's explicitly its job
⚠️ Pure vs impure
// 👎 Impure — depends on and mutates outside state
let total = 0;
function addToTotal(value) {
total += value; // side effect
return total;
}
// 👍 Pure — everything it needs comes in; nothing outside changes
function addToTotal(currentTotal, value) {
return currentTotal + value;
}
Pure functions are easier to test, reuse, and reason about because they can't surprise you with hidden effects.
Summary
🎉 Key Takeaways
- Functions come in three forms: declarations (hoisted), expressions, and arrow functions
- Arrow functions have no own
this— ideal for callbacks, wrong for methods that needthis - Parameters can have defaults, gather with rest (
...), and destructure objects/arrays - Scope nests: inner code sees outward;
let/constare block-scoped,varis not - Closures capture surrounding variables to create private state; higher-order functions and recursion build powerful abstractions
📚 Additional Resources
🚀 What's Next?
You've now got the full toolkit of core JavaScript: values, decisions, loops, and functions. Time to put it all to work. This week's Weekend Project guides you through building a responsive portfolio website with real HTML, CSS, and the JavaScript interactivity you've just learned.
🎉 You've reached the heart of the language!
Functions, scope, and closures are ideas you'll use every single day as a developer. Well done.