JavaScript fundamentals Foundation
Functions
Package logic into reusable functions, pass values in and get results out, pass functions to other functions, and meet scope, closures and recursion.
Why functions
You have already used functions: console.log, Number, Math.round. In Making decisions and Loops you also wrote a few of your own. This lesson explains them properly.
A function is a named block of code that you can run whenever you want, as often as you want. Running it is called calling the function. Functions let you:
- write a piece of logic once and reuse it,
- give that logic a name that says what it does,
- test it on its own, as you did with
checkAgeandcheckGuess.
Declaring and calling a function
A function declaration starts with the keyword function, then a name, the parameters in parentheses, and the body in braces:
function formatTask(title, done) {
const mark = done ? "x" : " ";
return `[${mark}] ${title}`;
}
console.log(formatTask("Buy milk", true));
console.log(formatTask("Write report", false));
node declaration.js and of the browser terminal[x] Buy milk [ ] Write report
- Parameters are the names in the definition:
titleanddone. Inside the function they work like variables. - Arguments are the actual values you pass when you call it:
"Buy milk"andtrue. The first argument goes to the first parameter, the second to the second. returnends the function and hands a value back to the caller. That value is the return value.
Return values
A function without return still gives something back: undefined. The same happens to a parameter you did not pass an argument for, and extra arguments are ignored:
function logTask(title) {
console.log(`Task: ${title}`);
}
const result = logTask("Buy milk");
console.log(result);
logTask(); // no argument
logTask("Call Ada", "extra"); // extra argument is ignored
node return.js and of the browser terminalTask: Buy milk undefined Task: undefined Task: Call Ada
Printing something and returning something are different. logTask prints, but returns nothing. A function that returns a value is more useful: the caller decides whether to print it, save it or send it over the network.
Function expressions and arrow functions
A function is a value, just like a number or a string. You can store it in a variable. That is a function expression. Because it has no name of its own after function, it is also an anonymous function:
const double = function (n) {
return n * 2;
};
console.log(double(21));
console.log(typeof double);
node expression.js and of the browser terminal42 function
Modern JavaScript usually writes this with an arrow function: the parameters, an arrow =>, and the body. When the body is a single expression, you can drop the braces and the return; the value of the expression is returned automatically:
const double = (n) => n * 2;
const add = (a, b) => a + b;
const hello = () => "Hello!"; // no parameters: empty ()
const describe = (title, done) => { // several lines: braces and return
const mark = done ? "x" : " ";
return `[${mark}] ${title}`;
};
console.log(double(21), add(2, 3), hello());
console.log(describe("Buy milk", false));
node arrow.js and of the browser terminal42 5 Hello! [ ] Buy milk
One difference between declarations and expressions: you can call a function declaration before the line where it is written, because JavaScript sets up declarations first. This is called hoisting. A function stored in a const cannot be used before that line runs.
console.log(square(4)); // works: declarations are hoisted
function square(n) {
return n * n;
}
try {
console.log(cube(2)); // fails: cube is not set yet
} catch (error) {
console.log(error.message);
}
const cube = (n) => n ** 3;
node hoisting.js and of the browser terminal16 Cannot access 'cube' before initialization
TIP
Both styles are fine. A common habit:function declarations for the main named functions of a file, and arrow functions for short functions passed to other functions, which you will see next.Default and rest parameters
A default parameter has a value after =. It is used when the argument is missing or undefined. A rest parameter, written with three dots, collects all remaining arguments into an array:
function greet(name = "friend", greeting = "Hello") {
return `${greeting}, ${name}!`;
}
console.log(greet("Ada"));
console.log(greet());
console.log(greet("Ada", "Welcome back"));
function sum(...numbers) {
let total = 0;
for (const n of numbers) total += n;
return total;
}
console.log(sum(1, 2, 3));
console.log(sum(10));
console.log(sum());
node parameters.js and of the browser terminalHello, Ada! Hello, friend! Welcome back, Ada! 6 10 0
The rest parameter must be the last one. It is always an array, even when no arguments are left (then it is empty).
Callbacks and higher-order functions
Because functions are values, you can pass a function as an argument to another function. The function you pass is called a callback: the other function "calls it back" when it needs to. A function that takes or returns another function is called a higher-order function.
function repeat(times, action) {
for (let i = 1; i <= times; i++) {
action(i);
}
}
repeat(3, (i) => console.log(`Reminder ${i}`));
function applyToAll(numbers, transform) {
const results = [];
for (const n of numbers) results.push(transform(n));
return results;
}
console.log(applyToAll([1, 2, 3], (n) => n * 10));
console.log(applyToAll([1.4, 2.6], Math.round));
node callback.js and of the browser terminalReminder 1 Reminder 2 Reminder 3 [ 10, 20, 30 ] [ 1, 3 ]
repeat knows how often to do something; the callback says what to do. results.push(…) adds an item to the end of an array. In the next lesson you will meet array methods such as map and filter, which are higher-order functions exactly like applyToAll.
A higher-order function can also return a function:
function multiplier(factor) {
return (n) => n * factor;
}
const triple = multiplier(3);
const half = multiplier(0.5);
console.log(triple(10), half(10));
node factory.js and of the browser terminal30 5
Pure functions
A pure function follows two rules:
- It gives the same result every time for the same arguments.
- It has no side effects: it changes nothing outside itself. No printing, no changing outside variables, no writing files.
let total = 0;
function addToTotal(amount) { // impure: changes a variable outside
total += amount;
return total;
}
function add(a, b) { // pure: only uses its arguments
return a + b;
}
console.log(addToTotal(5), addToTotal(5)); // same call, different results
console.log(add(5, 5), add(5, 5));
node pure.js and of the browser terminal5 10 10 10
Pure functions are easy to test and easy to trust: you only need to look at the arguments to know the result. A backend cannot be pure everywhere, because saving to a database is a side effect. A good habit is to keep the logic in pure functions and the side effects in a few clear places, like the thin keyboard part of the guessing game in the previous lesson.
Scope and closures
Variables created inside a function exist only inside it. The part of the code where a variable is visible is its scope. A function can read variables from outside it, but the outside cannot see in:
const appName = "Tasks";
function makeTitle(title) {
const prefix = `[${appName}]`; // appName comes from outside
return `${prefix} ${title}`;
}
console.log(makeTitle("Buy milk"));
console.log(typeof prefix); // prefix only exists inside makeTitle
node scope.js and of the browser terminal[Tasks] Buy milk undefined
A function also remembers the variables around it at the place where it was created, even after the outer function has finished. This is called a closure:
function createCounter() {
let count = 0;
return () => {
count++;
return count;
};
}
const nextId = createCounter();
const otherIds = createCounter();
console.log(nextId(), nextId(), nextId());
console.log(otherIds());
node closure.js and of the browser terminal1 2 3 1
createCounter finished long ago, but the arrow function it returned still has access to its count. Each call to createCounter makes a new, separate count, so the two counters do not share. Nothing outside can change count directly, which makes closures a simple way to keep data private. The lesson on scope goes deeper.
Recursion
A function can call itself. This is recursion. Every recursive function needs a base case: a situation where it stops calling itself and just returns. Without one it would call itself forever (JavaScript stops it with a "Maximum call stack size exceeded" error).
function factorial(n) {
if (n <= 1) return 1; // base case
return n * factorial(n - 1); // the same problem, one step smaller
}
console.log(factorial(5)); // 5 * 4 * 3 * 2 * 1
function countSubtasks(task) {
let count = 0;
for (const sub of task.subtasks) {
count += 1 + countSubtasks(sub);
}
return count;
}
const project = {
subtasks: [
{ subtasks: [] },
{ subtasks: [{ subtasks: [] }, { subtasks: [{ subtasks: [] }] }] },
],
};
console.log(countSubtasks(project));
node recursion.js and of the browser terminal120 5
factorial could just as well be a loop. Recursion shines on data that contains smaller copies of itself, like tasks with subtasks, folders with folders, or comments with replies. There the base case is a task with no subtasks: the loop runs zero times and the function returns 0.
Build: a calculator library
A library is a set of related functions that other code uses. You can group functions in an object: a set of named values between braces, where each value here is a function. You will learn objects in their own lesson; for now, calculator.add(2, 3) means "call the add function inside calculator".
The library validates its inputs. When something is wrong it throws an error, which stops the function; the caller can catch it with try/catch. You will learn errors fully in the lesson on errors. Below the library is a tiny test helper, and a list of checks:
function requireNumbers(...values) {
for (const v of values) {
if (typeof v !== "number" || Number.isNaN(v)) throw new Error(`Not a number: ${v}`);
}
}
const calculator = {
add(a, b) {
requireNumbers(a, b);
return a + b;
},
subtract(a, b) {
requireNumbers(a, b);
return a - b;
},
multiply(a, b) {
requireNumbers(a, b);
return a * b;
},
divide(a, b) {
requireNumbers(a, b);
if (b === 0) throw new Error("Cannot divide by zero");
return a / b;
},
sum(...numbers) {
requireNumbers(...numbers);
let total = 0;
for (const n of numbers) total += n;
return total;
},
average(...numbers) {
if (numbers.length === 0) throw new Error("Need at least one number");
return calculator.sum(...numbers) / numbers.length;
},
};
function check(label, run, expected) {
let actual;
try {
actual = run();
} catch (error) {
actual = `error: ${error.message}`;
}
console.log(`${actual === expected ? "PASS" : "FAIL"} ${label} -> ${actual}`);
}
check("add", () => calculator.add(2, 3), 5);
check("subtract", () => calculator.subtract(2, 5), -3);
check("multiply", () => calculator.multiply(4, 2.5), 10);
check("divide", () => calculator.divide(9, 3), 3);
check("divide by zero", () => calculator.divide(1, 0), "error: Cannot divide by zero");
check("sum", () => calculator.sum(1, 2, 3, 4), 10);
check("average", () => calculator.average(2, 4, 9), 5);
check("average of nothing", () => calculator.average(), "error: Need at least one number");
check("text input", () => calculator.add("2", 3), "error: Not a number: 2");
check("float", () => calculator.add(0.1, 0.2), 0.3);
node calculator.js and of the browser terminalPASS add -> 5 PASS subtract -> -3 PASS multiply -> 10 PASS divide -> 3 PASS divide by zero -> error: Cannot divide by zero PASS sum -> 10 PASS average -> 5 PASS average of nothing -> error: Need at least one number PASS text input -> error: Not a number: 2 FAIL float -> 0.30000000000000004
What is new here:
add(a, b) { … }inside the object is a short way to writeadd: function (a, b) { … }. Commas separate the functions.- Every function first calls
requireNumbers. Putting the shared check in one helper means it is written, and fixed, in one place. calculator.sum(...numbers): three dots in a call spread an array back out into separate arguments. It is the opposite of a rest parameter.checktakes a callback, so it can run the code insidetryand report a thrown error as a result instead of crashing."2"is rejected instead of silently turning2 + 3into"23". Always refuse bad input loudly.
The last test fails on purpose. It is the floating point error from the first lesson: a test found it, and exercise 2 fixes it. Real projects use a test runner like Vitest instead of a hand-made check; you will meet it in the lesson on testing, and the idea is exactly the same.
Practice
TRY IT YOURSELF
Add power and remainder
Add power(base, exponent) and remainder(a, b) functions, as arrow functions, and check them with 2 ** 10 and 17 % 5.
Show a solution
const power = (base, exponent) => base ** exponent;
const remainder = (a, b) => a % b;
console.log(power(2, 10));
console.log(remainder(17, 5));
node calc-more.js and of the browser terminal1024 2
In the library you would also call requireNumbers, and remainder should reject b === 0, because 17 % 0 is NaN.
TRY IT YOURSELF
Compare decimals safely
The float test failed because 0.1 + 0.2 is not exactly 0.3. Write closeTo(a, b) that returns true when two numbers differ by less than 0.000001, and use it.
Show a solution
const closeTo = (a, b) => Math.abs(a - b) < 0.000001;
console.log(0.1 + 0.2 === 0.3);
console.log(closeTo(0.1 + 0.2, 0.3));
console.log(closeTo(0.3, 0.4));
node close-to.js and of the browser terminalfalse true false
This is how test runners compare decimals too: Vitest calls it toBeCloseTo.
TRY IT YOURSELF
Count down with recursion
Write a recursive function countdown(n) that returns a string like "3 2 1 go". What is the base case?
Show a solution
function countdown(n) {
if (n <= 0) return "go";
return `${n} ${countdown(n - 1)}`;
}
console.log(countdown(3));
console.log(countdown(0));
node countdown.js and of the browser terminal3 2 1 go go
The base case is n <= 0. Using <= instead of === also protects you from a negative start, which would otherwise never reach 0.
Recap
- Parameters are the names in the definition; arguments are the values in the call.
returnhands back a value; without it you getundefined. - Functions are values: store them in variables (expressions, arrow functions) and pass them to other functions (callbacks).
- Default parameters fill in missing arguments; a rest parameter collects the remaining ones into an array.
- Pure functions depend only on their arguments and change nothing outside. Keep logic pure and side effects few.
- A closure remembers the variables around it. A recursive function calls itself and needs a base case.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.