Skip to main content

🧩 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.

graph LR A[Inputs
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.

graph TD A[Global Scope
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 need this
  • Parameters can have defaults, gather with rest (...), and destructure objects/arrays
  • Scope nests: inner code sees outward; let/const are block-scoped, var is 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.