Chapter 7: Functions
Functions are one of the most important parts of JavaScript. They let you group related code into named blocks that can be called and reused whenever needed. Every serious JavaScript program, no matter how small or large, relies on functions to organize logic and reduce repetition.
Functions in JavaScript are first-class citizens. This means you can store them in variables, pass them to other functions, and even return them as results. They are objects with their own properties and methods, and they form the backbone of modular and functional programming styles.
In this chapter you will explore the many ways to define and use functions. You will see how declarations differ from expressions, how this behaves inside arrow functions, and how to work with parameters, defaults, and rest arguments. You will also learn about return values, closures, and how to design functions that are predictable and free from unwanted side effects.
By the end of this chapter, you will have a clear understanding of how functions shape the structure and behavior of JavaScript programs, and how to use them effectively to build maintainable, reliable code.
Function declarations and expressions
In JavaScript, a function can be defined in two main ways: as a declaration or as an expression. Both create functions, but they behave differently in how and when they become available to the code around them.
Function declarations
A function declaration defines a named function using the function keyword, followed by the function name, a parameter list, and a block of code in braces. It looks like this:
function greet(name) {
console.log('Hello, ' + name + '!');
}
Because declared functions are hoisted, you can call them even before their definition appears in the code. This happens because the JavaScript interpreter loads all function declarations before executing the rest of the script.
sayHi('Robin');
function sayHi(name) {
console.log('Hi, ' + name);
}
Function expressions
A function expression creates a function as part of a larger expression. It may be anonymous (without a name) or named, and it is not hoisted in the same way as declarations. A common pattern is to assign the function to a variable:
const greet = function(name) {
console.log('Hello, ' + name + '!');
};
greet('Robin');
Unlike declarations, function expressions are only available after the line where they are defined. Trying to call them before that point will result in a reference error.
Immediate invocation
A special kind of function expression is the immediately invoked function expression (IIFE). It runs as soon as it is defined, often to create a private scope:
(function() {
console.log('This runs immediately');
})();
IIFEs were widely used before modern block scope with let and const became available. They still appear in legacy code and in libraries that encapsulate logic without polluting the global scope.
Arrow functions and this
Arrow functions provide a shorter, cleaner syntax for writing functions. They were introduced in ES6 and are often used for inline operations, callbacks, and expressions that return a single value.
Basic syntax
An arrow function uses the => operator instead of the function keyword. If there is only one parameter, the parentheses can be omitted, and if the body contains only one expression, the braces and return keyword can also be omitted:
const square = x => x * x;
console.log(square(4)); // 16
For multiple parameters or longer bodies, parentheses and braces are required:
const add = (a, b) => {
const result = a + b;
return result;
};
No binding of this
One of the key differences between arrow functions and regular functions is that arrow functions do not create their own this binding. Instead, they inherit this from the enclosing scope (known as lexical this binding). This makes them ideal for callbacks or methods that rely on the surrounding context.
const counter = {
count: 0,
start() {
setInterval(() => {
this.count++;
console.log(this.count);
}, 1000);
}
};
counter.start();
In the example above, the arrow function inside setInterval uses the same this value as the surrounding start method. If a regular function had been used instead, this would refer to the global object (or be undefined in strict mode).
new will throw a TypeError because they do not have their own prototype property.
When not to use arrow functions
Although concise and convenient, arrow functions are not always appropriate. They should be avoided when a function needs its own this, such as in class constructors or object methods that depend on method binding.
const user = {
name: 'Robin',
greet: () => {
console.log('Hello, ' + this.name);
}
};
user.greet(); // "Hello, undefined"
Here, this does not refer to user because the arrow function inherits this from the outer (global) scope. For methods that rely on this, use a regular function instead.
const user = {
name: 'Robin',
greet() {
console.log('Hello, ' + this.name);
}
};
user.greet(); // "Hello, Robin"
Parameters, defaults, and rest
Functions in JavaScript can accept any number of arguments, regardless of how many parameters are declared. The language is flexible about arguments: extra ones are ignored unless you capture them, and missing ones are simply undefined.
Basic parameters
Parameters are the named variables listed in a function’s definition. When a function is called, the arguments you provide are assigned to those parameters in order:
function greet(first, last) {
console.log('Hello, ' + first + ' ' + last);
}
greet('Robin', 'Nixon'); // Hello, Robin Nixon
If fewer arguments are passed than there are parameters, the remaining parameters become undefined by default.
greet('Robin'); // Hello, Robin undefined
undefined values.
Default parameters
You can assign default values to parameters directly in the function definition. If no argument (or undefined) is provided for that parameter, the default will be used:
function greet(name = 'Guest') {
console.log('Welcome, ' + name);
}
greet(); // Welcome, Guest
greet('Robin'); // Welcome, Robin
Default parameters can also depend on earlier parameters:
function repeat(str, times = 2) {
console.log(str.repeat(times));
}
repeat('Hi'); // HiHi
repeat('Hi', 3); // HiHiHi
undefined. Passing null will override the default with a null value.
The arguments object
Before modern syntax, JavaScript functions could access their arguments through the special arguments object. It is array-like (not a true array) and contains all arguments passed to the function:
function showAll() {
console.log(arguments.length + ' arguments:');
for (let i = 0; i < arguments.length; i++) {
console.log(arguments[i]);
}
}
showAll('a', 'b', 'c');
The arguments object still works in regular functions, but it is not available inside arrow functions. Modern JavaScript instead uses the rest parameter syntax.
Rest parameters
Rest parameters gather remaining arguments into an array. They are declared with three dots (...) before the parameter name, and they make handling variable argument lists much simpler:
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
Rest parameters must appear last in the parameter list, and only one rest parameter is allowed per function.
..., but rest gathers values while spread expands them.
Return values and early returns
A function’s return statement determines what value it produces when called. If a function does not explicitly return anything, it automatically returns undefined. The return keyword can be used to pass back any value or expression.
Returning values
The simplest use of return is to end a function and hand a result back to its caller:
function add(a, b) {
return a + b;
}
const result = add(2, 3);
console.log(result); // 5
Once a return statement is reached, the function stops executing immediately, and no further statements in its body are run.
function demo() {
return 'done';
console.log('This never runs');
}
return.
const square = x => x * x;
console.log(square(4)); // 16
Returning objects
When returning an object literal from an arrow function, you must wrap it in parentheses so that it is not confused with a function block:
const makeUser = (name, age) => ({ name, age });
console.log(makeUser('Robin', 55));
// { name: 'Robin', age: 55 }
Early returns
Sometimes you only want to continue a function if certain conditions are met. Using early return statements helps simplify logic and avoid deep nesting.
function divide(a, b) {
if (b === 0) {
console.log('Cannot divide by zero');
return;
}
console.log(a / b);
}
divide(10, 2); // 5
divide(10, 0); // Cannot divide by zero
This pattern improves readability by handling invalid or edge cases first, then proceeding only when the main logic applies.
return statements in overly complex functions. While early returns can simplify flow, too many can make the code harder to follow.
Returning multiple values
JavaScript functions can only return one value, but you can bundle multiple values together in an array or object:
function getStats(a, b) {
return {
sum: a + b,
product: a * b
};
}
const { sum, product } = getStats(3, 4);
console.log(sum, product); // 7 12
Returning structured data makes your functions more flexible and keeps related results together.
Closures in practice
A closure is one of JavaScript’s most powerful and distinctive features. It occurs when an inner function remembers and can access variables from its outer function, even after that outer function has finished running. This allows data to be kept private and preserved between function calls.
How closures work
When a function is defined inside another, it forms a lexical scope chain. The inner function has access not only to its own variables, but also to those of any outer functions and the global scope. This relationship persists for as long as the inner function exists.
function makeCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
In this example, the inner function maintains a reference to count, even though makeCounter has already completed execution. The variable lives on through the closure.
Using closures to encapsulate state
Closures are often used to encapsulate private data or configuration. Because the outer variables are not exposed directly, they can only be modified through the inner function’s logic:
function createAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
console.log('Deposited ' + amount);
},
withdraw(amount) {
if (amount > balance) {
console.log('Insufficient funds');
return;
}
balance -= amount;
console.log('Withdrew ' + amount);
},
getBalance() {
return balance;
}
};
}
const account = createAccount(100);
account.deposit(50);
account.withdraw(25);
console.log(account.getBalance()); // 125
Here, balance is private. It cannot be accessed directly from outside the returned object, but it persists through the closure inside the object’s methods.
Closures inside loops
Before let and const were introduced, closures in loops were a common source of confusion because var was function-scoped, not block-scoped. Using let fixes this by binding a new variable for each loop iteration:
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log('Iteration ' + i);
}, i * 1000);
}
This correctly prints 1, 2, and 3 in sequence, one per second. Using var instead would print 3 three times, because all functions would share the same variable reference.
Practical closure patterns
Closures underpin many useful patterns in JavaScript, such as module creation, function factories, and memoization. For example, this function remembers previous results to speed up repeated calls:
function memoize(fn) {
const cache = {};
return function(x) {
if (x in cache) {
console.log('Returning cached result');
return cache[x];
}
const result = fn(x);
cache[x] = result;
return result;
};
}
const square = memoize(x => x * x);
console.log(square(4)); // 16
console.log(square(4)); // Returning cached result
By retaining access to cache between calls, the closure allows results to persist without polluting the global scope. This technique is common in optimization and library design.
Pure functions and side effects
In programming, a pure function is one that always produces the same output for the same input and does not modify any state outside itself. Pure functions are predictable, testable, and central to functional programming techniques. In contrast, a side effect occurs when a function changes something in the outside world, such as a variable, file, or DOM element.
What makes a function pure
A pure function depends only on its parameters and does not rely on or alter external data. Given the same input, it will always return the same output:
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 5
console.log(add(2, 3)); // 5 (always the same)
Because it affects no external state and uses only its inputs, add() is completely pure.
Examples of side effects
A side effect occurs when a function interacts with or modifies something outside its scope. Common examples include writing to a file, changing a global variable, or updating the user interface:
let counter = 0;
function increment() {
counter++;
console.log(counter);
}
increment(); // modifies global variable
Even though this function works correctly, it is not pure because it changes counter, which exists outside the function’s local scope.
Avoiding unintended side effects
Side effects are sometimes necessary, especially in user interfaces and applications that interact with the real world. However, where possible, functions should be designed to minimize them. One common approach is to return a new value instead of modifying an existing one:
function addItem(list, item) {
return [...list, item];
}
const original = ['apple', 'banana'];
const updated = addItem(original, 'cherry');
console.log(original); // ['apple', 'banana']
console.log(updated); // ['apple', 'banana', 'cherry']
This version returns a new array rather than altering the original, making the function pure and the data flow predictable.
When side effects are acceptable
Not all side effects are bad. Some are required for a program to do anything useful, such as logging information, updating the DOM, or saving data to a database. The key is to manage and isolate them so they are intentional and predictable.
function logMessage(message) {
console.log('[LOG] ' + message);
}
This function has a clear and limited side effect (writing to the console), which is acceptable because it is part of its defined purpose.
Balancing purity and practicality
Completely pure programs are rare, but striving for purity within individual components can lead to cleaner, more reliable code. In many cases, a hybrid approach works best: keep most logic pure and centralize side effects in well-defined places, such as event handlers or API layers.
// Pure function
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
// Controlled side effect
function displayTotal(items) {
const total = calculateTotal(items);
document.querySelector('#total').textContent = total.toFixed(2);
}
Here, calculateTotal() is pure and reusable, while displayTotal() performs the necessary side effect of updating the web page. Keeping these roles separate makes the program both predictable and practical.