return really does, and why a function with no return gives undefined;Learn JS Series):We open, as always, with the worked solutions to last episode's three exercises. Do not just read them -- type them out, run them, and compare against your own attempt. The distance between "I think this loops correctly" and "I watched it print exactly what I expected" is precisely where the understanding settles in.
Exercise 1 - the multiplication table of 7:
for (let i = 1; i <= 10; i++) {
console.log(`7 x ${i} = ${7 * i}`);
}
The insight: the classic for loop shines when you need the counter itself. Here i is doing double duty -- it is both the multiplier in 7 * i and part of the printed line via the template literal. Starting at 1 and running to <= 10 (not < 10) is what gives you the full 7 x 1 through 7 x 10 range.
Exercise 2 - finding the first "wait":
const words = ["stop", "go", "stop", "wait", "stop"];
for (const [index, word] of words.entries()) {
if (word === "wait") {
console.log(`found at index ${index}`); // "found at index 3"
break;
}
}
The insight: entries() gives you index-and-value pairs so you can report where you found it, and break stops the search the instant the first match appears. Without the break, the loop would keep scanning past index 3 and waste work you already know you do not need.
Exercise 3 - halving until zero:
let n = 100;
while (n > 0) {
console.log(n);
n = Math.floor(n / 2);
}
// 100, 50, 25, 12, 6, 3, 1
The insight: the n = Math.floor(n / 2) line is the thing that moves the loop toward its exit. Remove it and n stays 100 forever, so n > 0 never turns false and the program hangs -- the classic infinite-loop mistake we warned about last time. Every while loop needs one line inside it that nudges the condition toward becoming false.
Right. Now we reach one of the most important topics in the whole language: functions. JavaScript is, at heart, a language built around functions -- everything from callbacks to closures to the entire async model grows out of what we cover today. Get this chapter solid and the rest of the series has firm ground to stand on.
A function is a reusable, named block of code that takes some inputs, does some work, and (usually) hands back a result. Functions let you name an idea once and use it everywhere, which keeps code short, readable, and -- this is the real prize -- correct in one place instead of copied wrong in ten:
function square(n) {
return n * n;
}
console.log(square(5)); // 25
console.log(square(9)); // 81
Here square is the name, n is the parameter (the input placeholder), n * n is the work, and return sends the result back out. When we write square(5), the 5 is the argument -- the actual value we pass in at the call site. So: parameter is the name in the definition, argument is the value at the call. Those two words get used loosely in conversation, but keeping them straight in your head clears up a lot of documentation that would otherwise read as fog.
Why bother wrapping three characters of arithmetic in a function at all? Because the moment the work is more than trivial -- validating an email, formatting a price, computing a discount -- you want it defined once. If the rule changes, you change it in a single spot and every caller gets the fix for free. That "single source of truth" property is the whole reason functions exist, and it is worth internalising early.
JavaScript gives you three syntaxes, and you will meet all of them in real code, so you cannot get away with learning just one.
A function declaration uses the function keyword followed by a name:
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 5
A function expression stores an anonymous function in a variable (we first glimpsed this back in episode 1):
const multiply = function (a, b) {
return a * b;
};
console.log(multiply(2, 3)); // 6
And an arrow function, the compact modern form, which we will study in real depth next phase:
const subtract = (a, b) => a - b;
console.log(subtract(5, 2)); // 3
Notice the arrow version has no function keyword, no braces, and no return -- when the body is a single expression, the arrow implicitly returns it. That is why (a, b) => a - b gives back the difference without you writing return at all. If you add braces, though, you are back to needing an explicit return:
const subtractVerbose = (a, b) => {
return a - b; // braces mean you MUST write return, just like a normal function
};
console.log(subtractVerbose(5, 2)); // 3
For now, treat these three as three ways to write the same idea. There is exactly one real behavioural difference between the declaration and the other two, and it is hoisting, which we get to shortly. Arrow functions also have a special relationship with the this keyword, but that deserves a whole episode of its own, so we park it deliberately rather than half-explain it here.
A function can take any number of parameters. If the caller passes fewer arguments than there are parameters, the missing ones are simply undefined:
function greet(name, greeting) {
return `${greeting}, ${name}!`;
}
console.log(greet("scipio", "Hi")); // "Hi, scipio!"
console.log(greet("scipio")); // "undefined, scipio!" - greeting was missing
That "undefined, scipio!" is almost never what you actually want. Modern JavaScript lets you give a parameter a default value that steps in when the argument is missing (or explicitly undefined):
function greet2(name, greeting = "Hello") {
return `${greeting}, ${name}!`;
}
console.log(greet2("scipio")); // "Hello, scipio!"
console.log(greet2("scipio", "Hey")); // "Hey, scipio!"
Defaults make functions forgiving and self-documenting: a reader sees immediately what happens when an argument is left out, without hunting through the body. One subtlety that trips people up: a default only fires for undefined, not for other falsy values like 0 or "" or null. Watch:
function volume(setting = 5) {
return `volume is ${setting}`;
}
console.log(volume()); // "volume is 5" - argument missing, default kicks in
console.log(volume(0)); // "volume is 0" - 0 is a real value, default does NOT apply
console.log(volume(undefined)); // "volume is 5" - undefined triggers the default
That behaviour is usually exactly what you want -- passing 0 on purpose should keep 0, not silently jump to 5. It is a deliberate design choice, and it is why defaults key off undefined specifically rather than off "anything falsy".
Defaults can also reference earlier parameters, which is a genuinely handy trick:
function makeRange(start, end = start + 10) {
return `${start}..${end}`;
}
console.log(makeRange(1)); // "1..11" - end defaulted using start
console.log(makeRange(1, 5)); // "1..5"
Here end defaults to start + 10, so a single argument still produces a sensible range. The parameters are evaluated left to right, which is why end is allowed to lean on start.
The return keyword does two distinct things at once: it hands a value back to the caller, and it immediately stops the function. Any code sitting after the return that actually runs never executes:
function classify(n) {
if (n > 0) return "positive";
if (n < 0) return "negative";
return "zero";
}
console.log(classify(-4)); // "negative" - stops at that return, never reaches "zero"
This "return exits early" behaviour is the engine behind the guard clause style, where you handle the special cases up front and bail out, leaving the main logic un-nested at the bottom:
function priceLabel(price) {
if (price == null) return "no price"; // bail out early for missing input
if (price === 0) return "free";
return `$${price}`; // the normal case, flat and clear
}
console.log(priceLabel(null)); // "no price"
console.log(priceLabel(0)); // "free"
console.log(priceLabel(9)); // "$9"
If a function has no return statement at all (or just a bare return; with nothing after it), it returns undefined. This is why functions written purely for their side effects -- logging, saving, printing -- give back undefined:
function logIt(msg) {
console.log(msg);
// no return statement
}
const result = logIt("hi"); // prints "hi"
console.log(result); // undefined - nothing was returned
Internalising "no return means undefined" quietly resolves a whole category of confusion. When some variable unexpectedly holds undefined, one of the first things to check is whether it came from a function that forgot to return its result -- a mistake even experienced people make when they refactor an expression into a helper and drop the return on the way.
Now the behaviour that genuinely separates function declarations from expressions. Function declarations are fully hoisted: JavaScript scans them before it runs your code line by line, so you can call a declared function before it appears in the file:
console.log(early(3)); // 9 - works, even though early is defined below!
function early(n) {
return n * n;
}
That is not a party trick, it is genuinely useful. It lets you put your main, high-level logic at the top of a file and tuck the helper functions below, so the file reads top-down like a summary followed by the supporting details -- the way a well-written article states its point first and the footnotes later.
Function expressions (and arrow functions) are not hoisted this way. The variable is hoisted (per the rules from episode 2), but it sits in the Temporal Dead Zone -- uninitialised -- until its line actually runs. Calling it early throws:
// console.log(late(3)); // ERROR (runtime): Cannot access 'late' before initialization
const late = function (n) {
return n * n;
};
console.log(late(3)); // 9 - fine, called AFTER the assignment ran
So the rule is: declared functions can be called from anywhere in their scope; functions stored in variables can only be called after the assignment line has executed. This single distinction explains a whole class of "X is not a function" errors that beginners hit -- they call a const arrow function a few lines above where it is defined, and JavaScript, quite reasonably, refuses. When you see that error, one of the first suspects is a call that ran before the definition.
A practical takeaway: because declarations hoist and read cleanly top-down, they are still a perfectly good default for named, standalone helpers. Arrow functions shine for short callbacks and for their this behaviour (later). You do not have to pick a side religiously -- you pick the form that reads best for the job in front of you.
One habit worth building from the very first day: a function should do one thing, and its name should say what that thing is. Small, single-purpose functions are easier to name, test, reuse, and reason about. Compare a vague do-everything blob against a clear little pipeline of focused helpers:
function isEven(n) {
return n % 2 === 0;
}
function double(n) {
return n * 2;
}
console.log(isEven(4)); // true
console.log(double(21)); // 42
Each of those does exactly one obvious thing, and you can tell what it does from the name alone without reading the body. Now watch how small functions compose into something larger while staying readable:
function isEven(n) {
return n % 2 === 0;
}
function double(n) {
return n * 2;
}
function doubleTheEvens(numbers) {
const result = [];
for (const n of numbers) {
if (isEven(n)) result.push(double(n));
}
return result;
}
console.log(doubleTheEvens([1, 2, 3, 4, 5, 6])); // [4, 8, 12]
doubleTheEvens reads almost like a sentence: for each number, if it is even, push its double. It leans on the two tiny helpers rather than re-implementing "even" and "double" inline, so if the definition of "even" ever needs to change (it will not, but bear with me), there is exactly one place to change it. As your programs grow, this discipline is what stops them turning into an unmaintainable knot. A reliable smell test: when a function's honest description needs the word "and" ("this validates the input AND saves it AND emails the user"), that is your signal to split it into three.
Quite some of you arrived here from the Learn Python Series or the Learn Rust Series, so a glance sideways sharpens the picture and shows which of JavaScript's choices are universal and which are its own quirks.
Python defines functions with def, and the ideas line up closely: parameters, a return that exits early, and None (Python's undefined/null cousin) returned automatically when you fall off the end without returning. Python's default arguments look almost identical to JavaScript's, with one famous trap of its own (mutable defaults are shared between calls), which JavaScript happens to avoid:
# Python: def, default argument, automatic None return
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("scipio")) # "Hello, scipio!"
def log_it(msg):
print(msg) # no return -> returns None
print(log_it("hi")) # prints "hi", then prints None
What Python does not have is JavaScript-style hoisting -- you cannot call a def before the line that defines it runs. In that respect Python behaves like a JavaScript function expression, not a declaration.
Rust is stricter and more explicit. Functions use fn, every parameter is typed, and the return type is spelled out after ->. There is no hoisting to worry about at the value level, and -- notably -- Rust has no default arguments at all, so you reach for other patterns instead. The neat twist: the last expression in a Rust function is its return value, no return keyword required (much like an arrow function's implicit return):
// Rust: typed params, explicit return type, last expression is returned
fn square(n: i32) -> i32 {
n * n // no semicolon, no `return` needed - this is the result
}
fn main() {
println!("{}", square(5)); // 25
}
Go also uses no hoisting concerns for local logic and requires typed parameters, but its signature party trick is multiple return values -- a function can hand back a result and an error side by side, which shapes the whole language's error style:
// Go: typed params, and the signature can return TWO values at once
func divide(a, b int) (int, bool) {
if b == 0 {
return 0, false // ok = false signals "could not divide"
}
return a / b, true
}
JavaScript reaches the same "return several things" goal by returning an array or an object and destructuring it -- a pattern we will lean on constantly later.
C is where the C-style function heritage ultimately comes from, though C spells it with a return type in front and no function keyword. C has typed parameters, an explicit return, and, like the others, no default arguments and no hoisting of the JavaScript kind. The throughline across all five languages is the same skeleton -- name, parameters, a body, a returned value -- and the differences are mostly about how much the type system is doing and whether the language lets you call a function before it textually appears. JavaScript is the loosest of the bunch: untyped parameters, defaults, and hoisted declarations. Knowing where it sits on that spectrum turns its rules from "arbitrary syntax" into "a set of deliberate trade-offs".
Let us close with a small program that leans on several of today's ideas at once -- default parameters, guard-clause returns, small composed helpers, and the difference between returning a value and just logging one. We will build a tiny price formatter that applies an optional discount and labels the result:
function isValidPrice(price) {
return typeof price === "number" && price >= 0;
}
function applyDiscount(price, percent = 0) {
const factor = 1 - percent / 100;
return Math.round(price * factor * 100) / 100; // round to 2 decimals
}
function priceTag(price, percent = 0) {
if (!isValidPrice(price)) return "invalid price"; // guard clause, bail out early
const final = applyDiscount(price, percent);
return final === 0 ? "free" : `$${final}`;
}
console.log(priceTag(100)); // "$100" - no discount passed, default 0
console.log(priceTag(100, 25)); // "$75" - 25% off
console.log(priceTag(50, 100)); // "free" - 100% off lands exactly on 0
console.log(priceTag("oops")); // "invalid price" - guard caught the bad input
Trace it through. priceTag first calls the tiny isValidPrice helper and, if the input is not a valid number, returns early with "invalid price" -- the guard clause keeps the bad case out of the main path. Otherwise it hands the work to applyDiscount, whose percent parameter defaults to 0, so calling priceTag(100) with no discount still behaves sensibly. The final ternary turns an exact 0 into the friendlier "free". Each function does one nameable job, defaults absorb the "missing argument" case, and return both produces the answer and stops the work at the right moment. That is the spirit of good function design: not clever, just clear, with each piece pulling its own small weight.
rectangleArea(width, height) that returns the area, with height defaulting to the same value as width when omitted (so a single argument gives a square's area). Test it with (4, 5) and with just (6).firstTruthy(a, b, c) that returns the first of its three arguments that is truthy, or the string "none" if all are falsy. (Hint: you learned an operator in episode 4 that makes this almost a one-liner.)const function expression and show that the same early call now throws. Explain the difference in one sentence.returns a result; the value at the call site is the argument, the name in the definition is the parameter.function name(){}), function expression (const f = function(){}), and arrow (const f = () => {}); arrows implicitly return a single-expression body.undefined; give parameters default values (greeting = "Hello") to make functions forgiving. Defaults fire only for undefined, not for 0 or "".return hands back a value and immediately stops the function; no return means the function yields undefined. This powers the guard-clause style.Next episode we go deeper into where variables live and how JavaScript looks them up: scope, nested scopes, and the Temporal Dead Zone we keep hinting at. It is the groundwork for closures, the crown jewel of the language.