So far your code has run straight down the page, once. That works until you need the same logic in three places — so you copy it, fix a bug in one copy, and forget the other two.
A function is a named, reusable block. You define it once and call it as often as you like. But functions buy you a second thing that matters even more: a private workspace called scope. Names you create inside a function live there and nowhere else, so a function cannot quietly overwrite the variables in the rest of your program. Most beginner 'why did my variable change?' bugs are really scope bugs, so we treat scope as the heart of this lesson, not an afterthought.
Two ways to write the same function:
function double(n) {
return n * 2;
}
const triple = (n) => n * 3; // arrow, short form: returns n * 3 automatically
// Defining printed nothing. Calling does the work.
console.log(double(5));
console.log(triple(5));
console.log(double(double(3)));
flowchart LR A["double(5)"] --> F["function body: n * 2"] F --> R["returns 10"] style F fill:#e0900b,color:#fff
Parameters, arguments, and return
Two words get used loosely but mean different things. In function double(n), the n is a parameter — a placeholder name in the definition. The 5 in double(5) is an argument — the actual value you hand over. Keeping those straight makes later error messages readable.
The body runs only when the function is called, never when it is defined. Whatever follows return is handed back to the caller; the function stops there. A function with no return still gives something back — undefined — which is the source of a classic bug: it printed the right answer, so it looked correct, but the value it actually returned was undefined. Anything that computes should return; reach for console.log only at the outermost layer that talks to a human.
An arrow function with a single expression returns that expression automatically — no return, no braces. Add braces and you must write return yourself.
const nums = [1, 2, 3, 4];
const doubled = nums.map((n) => n * 2);
console.log(doubled);
const total = nums.reduce((sum, n) => sum + n, 0);
console.log("sum =", total);
Scope: a function's names are its own
When a function runs, it gets a private workspace. A const or let declared inside the body exists only for the duration of the call and is thrown away when it ends. That is what stops a function from clobbering your program's state: if a global total exists and your function declares its own total, the local one shadows the global for the call — the global is untouched.
Reading is more permissive than writing. If a name is not defined locally, JavaScript walks outward along the scope chain to find it, so an inner function can read an outer variable. The reverse is not true: the outer scope cannot see names declared inside a function. The safe habit that follows from this is simple — take what you need as parameters, hand results back with return.
flowchart LR G["global scope"] --> M["function scope"] --> I["nested block scope"] I -.->|"not found locally?<br/>walk outward"| M M -.->|"still not found?"| G style G fill:#3776ab,color:#fff style M fill:#b45309,color:#fff style I fill:#e0900b,color:#fff
const name = "Ada"; // global
function greet() {
const name = "Grace"; // local shadows the global
return `Hi, ${name}`;
}
console.log(greet()); // "Hi, Grace"
console.log(name); // "Ada" — the global was never touched
// A closure: the inner function remembers `base` after makeAdder returns.
function makeAdder(base) {
return (n) => base + n;
}
const addTen = makeAdder(10);
console.log(addTen(5)); // 15
Closures: a function that remembers
When makeAdder returns, its parameter base ought to disappear with it. It does not — the returned arrow closes over base and keeps it alive. That captured binding is a closure, and it is how a function can carry private state around with it. Each call to makeAdder(10) and makeAdder(100) builds a separate, independent base; the two closures do not share one.
Closures are powerful, but they have one famous trap, and it comes from the older var keyword. let and const are block-scoped — a new copy per { ... } block, including each trip through a for loop. var is function-scoped, so a single var i is shared across every iteration of a loop. Close over that shared var and every callback ends up seeing the same final value.
flowchart TD
D["function f() {...}<br/>declaration"] --> H1["hoisted: callable above its line"]
E["const f = () => {...}<br/>expression"] --> H2["not hoisted: dead zone until its line"]
style H1 fill:#1e7f3d,color:#fff
style H2 fill:#b91c1c,color:#fff
console.log(typeof later); // "function" — declaration hoisted to the top
console.log(later()); // "ok"
function later() { return "ok"; }
const now = () => "now"; // referenced only AFTER this line — safe
console.log(now()); // "now"
What does this print? map squares each number into a new array.
const nums = [1, 2, 3]; console.log(nums.map((n) => n * n));
Square each of 1, 2, 3.
The result is a new array of the squares.
Write longestWord(words) that returns the longest string in the array words. (Assume at least one word, and if two tie, return the first.)
function longestWord(words) {
// return the longest string
}
Track the best word so far, starting from the first.
Compare
w.lengthtobest.lengthas you loop.
function longestWord(words) {
let best = words[0];
for (const w of words) {
if (w.length > best.length) best = w;
}
return best;
}
This builds three callbacks meant to each remember their own number — 0, 1, 2. But it uses var, so every callback closes over the same loop variable and they all return 3. Fix it by changing the one keyword that decides whether each iteration gets its own binding.
function makeHandlers() {
const handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(() => i);
}
return handlers;
}
var is function-scoped: one shared i for the whole loop.
let is block-scoped: a fresh i for each iteration, so each callback captures its own.
function makeHandlers() {
const handlers = [];
for (let i = 0; i < 3; i++) {
handlers.push(() => i);
}
return handlers;
}
What does this print? A function declaration is hoisted, so it is callable before the line that defines it. (The out array collects the two values.)
const out = [];
out.push(typeof greet); // before the definition line
function greet() { return "hi"; }
out.push(greet());
console.log(out.join(","));
Function declarations are hoisted to the top of their scope.
So typeof greet on the first line is already "function", and calling it works.
Write makeCounter() that returns an object with two methods: next() adds 1 to a private count and returns it, and reset() sets the count back to 0. The count must be private — it must not appear as a property on the returned object. Use a closure to hide it, the way makeAdder hid base above.
function makeCounter() {
// return { next, reset } closing over a private count
}
Declare let count = 0 inside makeCounter, before the return.
The returned methods close over count, so it stays private — c.count is undefined.
function makeCounter() {
let count = 0;
return {
next() { count += 1; return count; },
reset() { count = 0; }
};
}
Recap
function name(n) {}andconst name = (n) => {}both define functions; a single-expression arrow returns automatically.- Parameter is the placeholder in the definition; argument is the value at the call. A missing
returngives backundefined. let/constare block-scoped;varis function-scoped and shared across loop iterations — the root of the closure trap.- The scope chain lets an inner function read outward names; outer scopes cannot see in. A local name shadows an outer one.
- A closure is a function that keeps an outer variable alive after that outer function has returned.
- Declarations hoist (callable early);
const/letarrows do not — they sit in a dead zone until their line.
Next you'll meet higher-order functions — functions that accept or return other functions — which is exactly what map and makeAdder were quietly doing all along.