Episode 1 — Fundamentals / 1.20 — Functions
1.20.c — Return Values and Scope
In one sentence: The
returnstatement sends a value back to the caller and exits the function, while JavaScript's scope rules — global, function, block, and the lexical scope chain — determine which variables a function can see, culminating in closures: functions that remember the environment where they were created.
Navigation: ← 1.20.b — Parameters and Arguments · 1.20.d — Arrow Functions →
1. The return statement
return does two things:
- Sends a value back to the caller.
- Immediately exits the function — no code after
returnruns.
function double(n) {
return n * 2;
console.log("This never runs"); // unreachable code
}
const result = double(5);
console.log(result); // 10
Implicit undefined
If a function has no return statement, or return with no value, the function returns undefined:
function greet(name) {
console.log(`Hello, ${name}`);
// no return statement
}
const result = greet("Alice"); // logs "Hello, Alice"
console.log(result); // undefined
function earlyExit() {
return; // returns undefined explicitly
}
console.log(earlyExit()); // undefined
2. Returning multiple values
JavaScript functions can only return one value. To return multiple pieces of data, use an object or array and destructure at the call site:
Via object (named fields — clearest)
function getMinMax(arr) {
return {
min: Math.min(...arr),
max: Math.max(...arr),
};
}
const { min, max } = getMinMax([3, 7, 1, 9]);
console.log(min, max); // 1 9
Via array (positional — concise)
function divide(a, b) {
const quotient = Math.floor(a / b);
const remainder = a % b;
return [quotient, remainder];
}
const [q, r] = divide(17, 5);
console.log(q, r); // 3 2
When to choose which:
- Object — when the caller benefits from named keys (self-documenting).
- Array — when order is obvious or you want shorter syntax (like coordinate pairs
[x, y]).
3. Early return pattern (guard clauses)
Instead of deeply nested if/else, use early returns to handle edge cases first:
Without early return (deep nesting)
function processOrder(order) {
if (order) {
if (order.items.length > 0) {
if (order.paymentMethod) {
// actual logic buried deep
return chargeAndShip(order);
} else {
return "No payment method";
}
} else {
return "No items in order";
}
} else {
return "No order provided";
}
}
With early return (flat and readable)
function processOrder(order) {
if (!order) return "No order provided";
if (order.items.length === 0) return "No items in order";
if (!order.paymentMethod) return "No payment method";
// Happy path — the main logic
return chargeAndShip(order);
}
Benefits:
- Reduces indentation and cognitive load.
- Edge cases are visible at the top.
- The "happy path" is the last block — easy to find.
4. Scope — where variables live
Scope determines where a variable is accessible. JavaScript has three main scope levels:
Global scope
Variables declared outside any function or block are global — accessible everywhere:
const appName = "MyApp"; // global
function showApp() {
console.log(appName); // accessible
}
showApp(); // "MyApp"
Caution: Global variables can be overwritten by any code — minimize their use.
Function scope
Variables declared with var, let, or const inside a function are local to that function:
function calculate() {
var x = 10;
let y = 20;
const z = 30;
console.log(x + y + z); // 60
}
calculate();
// console.log(x); // ReferenceError — x is not defined
Block scope (let and const only)
let and const are scoped to the nearest enclosing block ({ }):
if (true) {
let blockVar = "I'm block-scoped";
const alsoBlock = "Me too";
var notBlock = "I escape the block!";
}
// console.log(blockVar); // ReferenceError
// console.log(alsoBlock); // ReferenceError
console.log(notBlock); // "I escape the block!" — var ignores block scope
Scope comparison table
| Keyword | Function-scoped? | Block-scoped? | Hoisted? | TDZ? |
|---|---|---|---|---|
var | Yes | No | Yes (as undefined) | No |
let | Yes | Yes | Yes (uninitialized) | Yes |
const | Yes | Yes | Yes (uninitialized) | Yes |
TDZ = Temporal Dead Zone — the period between entering the scope and the declaration line. Accessing a let/const variable in the TDZ throws a ReferenceError.
5. Lexical scoping (static scoping)
JavaScript uses lexical scoping — a function's scope is determined by where it is written in the source code, not where it is called.
const outer = "I'm outer";
function parent() {
const inner = "I'm inner";
function child() {
console.log(outer); // "I'm outer" — visible
console.log(inner); // "I'm inner" — visible
}
child();
}
parent();
The child function can access inner because child is written inside parent. Even if you somehow called child from a different location, it would still look up the scope where it was defined, not where it was invoked.
6. Scope chain
When JavaScript encounters a variable, it looks up the scope chain:
- Current function scope.
- Enclosing function scope (parent).
- Next enclosing scope, and so on...
- Global scope.
- If not found anywhere:
ReferenceError.
const a = "global";
function outer() {
const b = "outer";
function middle() {
const c = "middle";
function inner() {
const d = "inner";
console.log(a); // "global" — found at global level
console.log(b); // "outer" — found at outer level
console.log(c); // "middle" — found at middle level
console.log(d); // "inner" — found at current level
}
inner();
}
middle();
}
outer();
Scope chain for inner():
inner scope → middle scope → outer scope → global scope
d c b a
7. Variable shadowing
When a variable in an inner scope has the same name as one in an outer scope, the inner variable shadows (hides) the outer one:
const color = "red"; // global
function paint() {
const color = "blue"; // shadows the global 'color'
console.log(color); // "blue"
function detail() {
const color = "green"; // shadows paint's 'color'
console.log(color); // "green"
}
detail();
}
paint();
console.log(color); // "red" — the global is untouched
Shadowing is legal but can be confusing. Linters like ESLint have a no-shadow rule to flag this.
8. Closures — functions that remember
A closure is created when a function retains access to variables from its lexical scope, even after the outer function has finished executing.
The fundamental example
function createGreeter(greeting) {
// 'greeting' is in the outer scope
return function (name) {
// This inner function "closes over" greeting
return `${greeting}, ${name}!`;
};
}
const sayHello = createGreeter("Hello");
const sayHola = createGreeter("Hola");
// createGreeter has already returned, but the inner function remembers 'greeting'
console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayHola("Bob")); // "Hola, Bob!"
Why closures work
When createGreeter("Hello") runs:
- A new scope is created with
greeting = "Hello". - The returned inner function keeps a reference to that scope.
- Even after
createGreeterfinishes, the scope is not garbage-collected because the inner function still references it.
9. Closure practical examples
Counter with private state
function createCounter(start = 0) {
let count = start;
return {
increment() { return ++count; },
decrement() { return --count; },
getCount() { return count; },
reset() { count = start; return count; },
};
}
const counter = createCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.getCount()); // 11
counter.reset();
console.log(counter.getCount()); // 10
// 'count' is NOT accessible from outside
// console.log(counter.count); // undefined — truly private
Private variables / data encapsulation
function createBankAccount(initialBalance) {
let balance = initialBalance; // private
const transactions = []; // private
return {
deposit(amount) {
if (amount <= 0) throw new Error("Deposit must be positive");
balance += amount;
transactions.push({ type: "deposit", amount, date: new Date() });
return balance;
},
withdraw(amount) {
if (amount <= 0) throw new Error("Withdrawal must be positive");
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
transactions.push({ type: "withdrawal", amount, date: new Date() });
return balance;
},
getBalance() {
return balance;
},
getTransactions() {
return [...transactions]; // Return copy, not the original
},
};
}
const account = createBankAccount(100);
account.deposit(50); // 150
account.withdraw(30); // 120
console.log(account.getBalance()); // 120
// account.balance — undefined (private)
Function factory
function createMultiplier(factor) {
return function (num) {
return num * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const percent = createMultiplier(0.01);
console.log(double(50)); // 100
console.log(triple(50)); // 150
console.log(percent(250)); // 2.5
Memoization (caching results)
function memoize(fn) {
const cache = {}; // closed over — persists across calls
return function (...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log("Cache hit:", key);
return cache[key];
}
console.log("Computing:", key);
const result = fn(...args);
cache[key] = result;
return result;
};
}
const slowSquare = (n) => {
// Simulate expensive operation
return n * n;
};
const memoSquare = memoize(slowSquare);
memoSquare(5); // "Computing: [5]" → 25
memoSquare(5); // "Cache hit: [5]" → 25
memoSquare(3); // "Computing: [3]" → 9
10. Common closure pitfall — loop variable capture
The classic bug
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // 3, 3, 3 — NOT 0, 1, 2
}, 100);
}
Why? var is function-scoped. All three callbacks share the same i. By the time they run, the loop has finished and i is 3.
Fix 1: Use let (block-scoped)
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // 0, 1, 2 — each iteration gets its own 'i'
}, 100);
}
Fix 2: IIFE (pre-ES6 pattern)
for (var i = 0; i < 3; i++) {
(function (captured) {
setTimeout(function () {
console.log(captured); // 0, 1, 2
}, 100);
})(i);
}
Fix 3: Closure via factory function
function makeLogger(value) {
return function () {
console.log(value);
};
}
for (var i = 0; i < 3; i++) {
setTimeout(makeLogger(i), 100); // 0, 1, 2
}
Modern best practice: Always use let in for loops. The var loop bug is one of the most common JavaScript interview questions.
Key takeaways
returnsends a value back and exits the function; noreturnmeans the function returnsundefined.- Return multiple values via an object (named) or array (positional), then destructure at the call site.
- Early return guard clauses keep code flat and readable — handle edge cases first.
- Scope comes in three levels: global, function, and block (
let/constonly). - Lexical scoping means a function's variable access is determined by where it is written, not where it is called.
- The scope chain walks outward from the current scope to global; variables in closer scopes shadow outer ones.
- A closure is a function that retains access to its lexical scope, even after the outer function returns — enabling private state, factories, and memoization.
- The classic
var-in-a-loop bug is caused by closures sharing the same function-scoped variable — fix it withlet.
Explain-It Challenge
Explain without notes:
- What does a function return if it has no
returnstatement? - Describe the scope chain — how does JavaScript resolve a variable name?
- What is a closure and why is it useful?
- Why does
var iin aforloop causesetTimeoutcallbacks to all print the same value?
Navigation: ← 1.20.b — Parameters and Arguments · 1.20.d — Arrow Functions →