Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.
Chapter 3: Functions
Estimated time: ~45 minutes · Chapter type: Concept
What you'll learn
- Three ways to write a function, and when to reach for each
- How scope and closures actually work
- Recursion, and when it's the right tool
- A worked example of turning a messy script into small functions
Defining a function
A function packages a piece of code so you can run it by name, as many times as you like, optionally feeding it different input each time.
function square(n) {
return n * n;
}
square(5); // 25
n is a parameter — a local binding that receives whatever value is passed in when the function is called (that value is the argument). return hands a value back to the caller and immediately exits the function. A function with no return implicitly returns undefined.
Bindings and scopes
Every binding lives in a scope — the region of code where it's visible. Parameters and any let/const declared inside a function body are local to that function; they don't exist outside it and don't collide with same-named bindings elsewhere.
function double(n) {
const result = n * 2; // local to double()
return result;
}
double(21);
// result; // ReferenceError — result doesn't exist out here
Bindings declared outside any function are global — visible everywhere. Global bindings are convenient in tiny scripts and a liability in real programs: any function can silently depend on or overwrite them, which makes code hard to reason about in isolation. Keep as much as possible local.
Nested scope
Functions can be defined inside other functions, and inner functions can see the bindings of every function they're nested inside (this is called lexical scoping — visibility is determined by where code is written, not how it's called):
function makeGreeting(greeting) {
function forPerson(name) {
return `${greeting}, ${name}!`; // sees "greeting" from the outer function
}
return forPerson('Alex');
}
makeGreeting('Hey'); // "Hey, Alex!"
The reverse isn't true: makeGreeting cannot see anything declared inside forPerson.
Functions as values
Functions are ordinary values in JavaScript — you can store them in bindings, pass them as arguments, and return them from other functions. This is one of JavaScript's most important features, and Chapter 5 builds heavily on it.
const add = function (a, b) {
return a + b;
};
const operations = { add, subtract: (a, b) => a - b };
operations.add(2, 3); // 5
Arrow functions
Arrow function syntax is a more compact alternative, and it's the default style in most modern codebases for short functions:
const square = (n) => n * n; // implicit return, single expression
const clamp = (n, min, max) => { // block body needs an explicit return
if (n < min) return min;
if (n > max) return max;
return n;
};
Arrow functions also handle the keyword this differently from regular functions (they don't have their own this — they inherit it from the surrounding scope), which matters most once you're writing methods and classes in Chapter 6.
Declaration vs. expression
function namedDeclaration() {} // function declaration — hoisted, can be called before it's defined in the file
const namedExpression = function () {}; // function expression — behaves like any other binding
Function declarations are "hoisted" — JavaScript makes them available throughout their enclosing scope before execution even starts, so you can call one above where it's written in the file. Function expressions (including arrow functions) follow normal binding rules and are only usable after the line that defines them runs.
The call stack
Every time a function calls another, JavaScript remembers where to "come back to" once the inner call finishes, using a structure called the call stack. Each function call pushes a new frame onto the stack; returning pops it off.
function a() { b(); }
function b() { c(); }
function c() { throw new Error('boom'); }
a();
// Uncaught Error: boom
// at c
// at b
// at a
That stack trace, read from top to bottom, tells you exactly which chain of calls led to the error — an essential debugging tool you'll use constantly (more in Chapter 8). The stack has a finite size; a function that calls itself without ever stopping will eventually throw a RangeError: Maximum call stack size exceeded — a "stack overflow."
Optional and default parameters
JavaScript doesn't complain if you call a function with too few or too many arguments — missing ones simply become undefined. You can give a parameter a fallback value directly in the signature:
function greet(name, greeting = 'Hello') {
return `${greeting}, ${name}!`;
}
greet('Sam'); // "Hello, Sam!"
greet('Sam', 'Welcome'); // "Welcome, Sam!"
Closures
A closure happens when an inner function "remembers" the variables from the scope it was created in, even after that outer scope has finished running. This is one of the most powerful — and initially confusing — ideas in JavaScript.
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3 — each call sees the *same* count, private to this counter
makeCounter finishes running immediately, yet the returned function still has access to count. Each call to makeCounter() creates a fresh, independent count — this is how you get private state in JavaScript without classes.
Recursion
A function that calls itself is recursive. Recursion shines whenever a problem naturally breaks down into smaller versions of itself:
function factorial(n) {
if (n <= 1) return 1; // base case — stops the recursion
return n * factorial(n - 1); // recursive case — smaller sub-problem
}
factorial(5); // 120
Every recursive function needs a base case (a condition that stops the recursion) or it will recurse forever and blow the call stack. Here's a slightly richer example — flattening an arbitrarily nested array, a problem that's awkward to express with loops but natural with recursion:
function flatten(list) {
const result = [];
for (const item of list) {
if (Array.isArray(item)) {
result.push(...flatten(item)); // recurse into nested arrays
} else {
result.push(item);
}
}
return result;
}
flatten([1, [2, 3, [4, [5]]], 6]); // [1, 2, 3, 4, 5, 6]
Recursion isn't always the fastest option — a loop-based version usually runs faster and uses less memory — but it's frequently the clearest way to express the solution, and clarity often wins.
Growing functions: an example
Say you start with a one-off script:
const cartTotal = 12.5 + 8 + 3.25;
console.log(`Total: $${cartTotal.toFixed(2)}`);
That's fine for a single use, but the moment you need to do this for a second cart, duplicating the math is a mistake waiting to happen. Extracting a function fixes that — and gives the operation a name, which is documentation in itself:
function cartTotal(prices) {
return prices.reduce((sum, price) => sum + price, 0); // reduce: Ch. 5
}
function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}
console.log(formatCurrency(cartTotal([12.5, 8, 3.25])));
console.log(formatCurrency(cartTotal([99.99, 1.01])));
Notice the second version also splits computing the total from formatting it — two functions, each doing one thing. That's the core skill this chapter is really teaching: recognizing a lump of logic that deserves its own name, and giving it one.
Functions and side effects
A function is pure if, given the same input, it always returns the same output and doesn't change anything outside itself (no modifying global state, no writing to the console, no mutating an argument). square(n) above is pure; a function that logs to the console or pushes into a shared array is not — it has a side effect.
Neither is "wrong," but pure functions are dramatically easier to test, reuse, and reason about, because you never have to ask "what else does calling this affect?" A good default: keep the core logic of your program in pure functions, and push side effects (logging, network calls, DOM updates) to the edges.
Key takeaways
- Local bindings live only inside the function (or block) where they're declared; keep globals to a minimum.
- Lexical scoping means an inner function can always see the bindings of every function it's nested inside.
- A closure is an inner function that keeps access to its outer function's bindings after that function has returned — the standard way to get private state.
- Every recursive function needs a base case, or it will overflow the call stack.
- Prefer small, pure functions where practical; they're easier to test and reuse.
Try it yourself
- Write a function
isPalindrome(str)that returnstrueif a string reads the same forwards and backwards. - Write
makeMultiplier(factor)that returns a new function which multiplies its argument byfactor(this is a closure, just likemakeCounterabove). - Write a recursive function
sumDigits(n)that adds up the digits of a positive integer (e.g.,sumDigits(1234)→10).
Hints
- Compare the string to its own reversed copy —
str.split('').reverse().join('')reverses a string. - The returned function should close over
factorthe same way the counter closes overcount. - Base case: a single-digit number returns itself. Recursive case:
n % 10gives the last digit;Math.floor(n / 10)gives the rest.
Summary
Functions are how you turn "a bunch of statements" into "a program made of clearly named, reusable pieces." Scope and closures govern what each piece can see; recursion gives you a second way to repeat work besides loops. Next, we look at the data side of the equation: objects and arrays, the structures you'll actually be passing into and out of all these functions.