Learn / JavaScript fundamentals
LESSON 10 OF 84

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.

  • 40 min to read and try
  • You need: The lessons up to Loops
  • You build: A small calculator library, checked by its own tests
Test yourself

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 checkAge and checkGuess.

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:

declaration.js
function formatTask(title, done) {
  const mark = done ? "x" : " ";
  return `[${mark}] ${title}`;
}

console.log(formatTask("Buy milk", true));
console.log(formatTask("Write report", false));
Output of node declaration.js and of the browser terminal
[x] Buy milk
[ ] Write report
  • Parameters are the names in the definition: title and done. Inside the function they work like variables.
  • Arguments are the actual values you pass when you call it: "Buy milk" and true. The first argument goes to the first parameter, the second to the second.
  • return ends 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:

return.js
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
Output of node return.js and of the browser terminal
Task: 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:

expression.js
const double = function (n) {
  return n * 2;
};

console.log(double(21));
console.log(typeof double);
Output of node expression.js and of the browser terminal
42
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:

arrow.js
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));
Output of node arrow.js and of the browser terminal
42 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.

hoisting.js
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;
Output of node hoisting.js and of the browser terminal
16
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:

parameters.js
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());
Output of node parameters.js and of the browser terminal
Hello, 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.

callback.js
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));
Output of node callback.js and of the browser terminal
Reminder 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:

factory.js
function multiplier(factor) {
  return (n) => n * factor;
}

const triple = multiplier(3);
const half = multiplier(0.5);

console.log(triple(10), half(10));
Output of node factory.js and of the browser terminal
30 5

Pure functions

A pure function follows two rules:

  1. It gives the same result every time for the same arguments.
  2. It has no side effects: it changes nothing outside itself. No printing, no changing outside variables, no writing files.
pure.js
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));
Output of node pure.js and of the browser terminal
5 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:

scope.js
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
Output of 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:

closure.js
function createCounter() {
  let count = 0;
  return () => {
    count++;
    return count;
  };
}

const nextId = createCounter();
const otherIds = createCounter();

console.log(nextId(), nextId(), nextId());
console.log(otherIds());
Output of node closure.js and of the browser terminal
1 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).

recursion.js
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));
Output of node recursion.js and of the browser terminal
120
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:

calculator.js
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);
Output of node calculator.js and of the browser terminal
PASS 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 write add: 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.
  • check takes a callback, so it can run the code inside try and report a thrown error as a result instead of crashing.
  • "2" is rejected instead of silently turning 2 + 3 into "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
calc-more.js
const power = (base, exponent) => base ** exponent;
const remainder = (a, b) => a % b;

console.log(power(2, 10));
console.log(remainder(17, 5));
Output of node calc-more.js and of the browser terminal
1024
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
close-to.js
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));
Output of node close-to.js and of the browser terminal
false
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
countdown.js
function countdown(n) {
  if (n <= 0) return "go";
  return `${n} ${countdown(n - 1)}`;
}

console.log(countdown(3));
console.log(countdown(0));
Output of node countdown.js and of the browser terminal
3 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. return hands back a value; without it you get undefined.
  • 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.