Week 2: Functions, Scope & Hoisting

Week 1 covered values and how a program branches. This week is about functions — the three ways JavaScript lets you write one, how parameters actually work, and the scoping rules that decide where a variable is visible from. Understanding hoisting properly here pays off directly when closures arrive in Week 3.

Phase 1 of 8 Week 2 of 14 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write functions as declarations, expressions & arrow functions, and choose between them
  • Use default & rest parameters to write flexible function signatures
  • Explain block vs. function scope, and what hoisting actually does at runtime

1. Three Ways to Write a Function

JavaScript has three distinct syntaxes for creating a function — they overlap a lot, but each has a situation where it's the right (or only) choice:

function declaration
function greet(name) {
  return `Hello, ${name}!`;
}

greet("Ada"); // "Hello, Ada!"
function expression
const greet = function (name) {
  return `Hello, ${name}!`;
};

greet("Ada"); // "Hello, Ada!"
arrow function
const greet = (name) => {
  return `Hello, ${name}!`;
};

// A single expression can skip the braces and `return` entirely:
const greetShort = (name) => `Hello, ${name}!`;

greetShort("Ada"); // "Hello, Ada!"

A function declaration is hoisted in full (more on that below) and reads clearly as a named, top-level operation. A function expression stores an (often anonymous) function in a variable, useful when you want to pass a function around like any other value. An arrow function is the terse, modern default for short callbacks and one-line logic — and, importantly, doesn't have its own this (that distinction matters starting Week 3).

A practical default

Use a named function declaration for top-level, reusable logic — it reads clearly and is hoisted, so declaration order doesn't matter. Use an arrow function for short callbacks passed directly into something else, like array.map(x => x * 2). You'll see both constantly in real code.

2. Default & Rest Parameters

Two features make function signatures far more flexible than a fixed parameter list:

default parameters
function greet(name, greeting = "Hello") {
  return `${greeting}, ${name}!`;
}

greet("Ada");            // "Hello, Ada!"
greet("Ada", "Hi");        // "Hi, Ada!"
rest parameters
function sum(...numbers) {
  return numbers.reduce((total, n) => total + n, 0);
}

sum(1, 2, 3);        // 6
sum(10, 20, 30, 40);   // 100

A default parameter only kicks in when the argument is undefined — passing null or 0 explicitly does not trigger the default. The rest parameter (...numbers) must come last in the parameter list, and collects any number of remaining arguments into a real array — useful whenever a function needs to accept a variable number of inputs.

3. Block vs. Function Scope

Scope determines where a variable is visible from. This is exactly where let/const and var genuinely diverge:

block scope: let & const
if (true) {
  let blockScoped = "only visible in here";
  console.log(blockScoped); // works
}

console.log(blockScoped); // ReferenceError: blockScoped is not defined
function scope: var
if (true) {
  var functionScoped = "leaks out of the block";
}

console.log(functionScoped); // "leaks out of the block" -- no error!

let and const are scoped to the nearest enclosing {'{'}...{'}'} block — an if, a for loop, any block at all. var ignores block boundaries entirely and is scoped to the nearest enclosing function (or the global scope, if there is no enclosing function) — which is exactly why it leaked out of the if block above. This is the single biggest practical reason to avoid var: its scope doesn't match what the code's indentation visually suggests.

4. Hoisting

Hoisting is JavaScript's behavior of processing certain declarations before running the rest of the code in a scope — but the three declaration types behave very differently under it:

function declarations: fully hoisted
sayHi(); // "Hi!" -- works, even though called before the declaration below

function sayHi() {
  console.log("Hi!");
}
let/const: hoisted, but not initialized
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
var: hoisted AND initialized to undefined
console.log(y); // undefined -- no error, but not the value either
var y = 5;

A function declaration is hoisted with its entire body, so it can be called before the line it's written on. let and const are technically hoisted too, but stay in an inaccessible "temporal dead zone" until their declaration line actually runs — accessing them earlier throws, rather than silently returning undefined. var is hoisted and pre-initialized to undefined, which is exactly the kind of silent-bug-waiting-to-happen behavior that makes let/const the safer default.

Function expressions and arrow functions are NOT hoisted like declarations

Only a function name() {'{}'} declaration gets the full hoisting treatment. const greet = function () {'{}'} or const greet = () => {'{}'} follow const's hoisting rules instead — calling greet() before that line throws, exactly like any other const.

5. Return Values & Pure Functions

A function that doesn't explicitly return anything returns undefined. Beyond that, a useful habit to build from week one: prefer writing pure functions — functions whose output depends only on their inputs, with no side effects on anything outside them:

impure vs. pure
let total = 0;

// Impure -- depends on and mutates something outside itself
function addToTotal(n) {
  total += n;
}

// Pure -- same inputs always produce the same output, nothing external touched
function add(a, b) {
  return a + b;
}

add(2, 3); // always 5, no matter what else is happening in the program

Pure functions are dramatically easier to test, reason about, and reuse — a function like add can be called anywhere, any number of times, and never surprises you. This habit pays off even more once state management shows up in a framework course later.

6. Hands-on Exercise

Hands-on

Build a small shopping-cart total calculator

Apply function declarations, default/rest parameters and pure-function thinking together.

Requirements:

  1. Create a file cart.js. Write a pure function function priceWithTax(price, taxRate = 0.08) that returns the price including tax.
  2. Write a second pure function function cartTotal(...prices) using a rest parameter, that sums an array of prices using reduce.
  3. Rewrite cartTotal as an arrow function assigned to a const, and confirm it still works identically.
  4. Call cartTotal with at least four prices, log the raw total, then log the total after passing it through priceWithTax.
  5. Add a comment above each function explaining, in one sentence, why it qualifies as "pure."
Hint

If reduce is unfamiliar, it's covered in full in Week 5 — for now, numbers.reduce((total, n) => total + n, 0) is a safe pattern to copy directly to sum an array.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can a function declaration be called before the line it's written on, but a function expression can't?

A function name() {'{}'} declaration is fully hoisted — its entire definition is processed before the rest of the code in that scope runs. A function expression like const greet = function () {'{}'} is just a const assignment, so it follows const's own hoisting rules: the variable exists but is inaccessible (the "temporal dead zone") until its declaration line actually executes.

Q2

A variable declared with var inside an if block is still accessible after the block ends. Why?

var is function-scoped, not block-scoped — it ignores {'{'}...{'}'} boundaries entirely and belongs to the nearest enclosing function (or the global scope). let and const, by contrast, are genuinely block-scoped and would correctly throw a ReferenceError if accessed outside the if block.

Q3

Given function greet(name, greeting = "Hello"), what does greet("Ada", null) return?

"null, Ada!" (i.e. greeting is literally null) — a default parameter only activates when the argument is undefined. Passing null explicitly is a real, intentional value as far as the default parameter is concerned, so the default is skipped. Only calling greet("Ada") — omitting the second argument entirely — would trigger "Hello".

Q4

Why are pure functions easier to test than functions with side effects?

A pure function's output depends only on its own inputs and touches nothing outside itself, so calling it with the same arguments always produces the same result — a test just checks input against expected output, with no need to set up or inspect any external state. A function with side effects (like mutating an outer variable) requires a test to also track and verify that external state, which is more work and more fragile.