<< Home

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.

💡 Functions can be defined anywhere in your code and even inside other functions. This allows for powerful patterns such as closures and encapsulation.

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);
}
⚠️ Only the declaration itself is hoisted, not the call. The function body is known before runtime, so it can be invoked from anywhere after or before its definition in the same scope.

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.

💡 Use named function expressions when debugging or when stack traces matter. A named expression gives the function an internal name that appears in error messages and logs.

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;
};
💡 Arrow functions are always expressions, never declarations. They are evaluated at runtime like other expressions, and cannot be hoisted.

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).

⚠️ Arrow functions cannot be used as constructors. Trying to use them with 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
⚠️ JavaScript does not enforce argument counts. Always consider providing defaults or validating inputs to avoid unexpected 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
💡 Default parameters only apply when an argument is explicitly 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.

⚠️ Do not confuse the rest syntax in parameter lists with the spread syntax used in function calls and array literals. Both use ..., 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');
}
💡 In arrow functions with a single expression and no braces, the result of that expression is returned automatically without using 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.

⚠️ Avoid using multiple 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.

💡 You can think of a closure as a "backpack" that an inner function carries, containing all the variables it needs from its creation environment.

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.

⚠️ Be cautious when closures unintentionally retain references to large data or DOM elements. This can prevent garbage collection and lead to memory leaks in long-running applications.

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.

💡 Pure functions are easy to reason about and test because they have no hidden dependencies or unpredictable behavior.

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.

⚠️ Mutating shared data or relying on global variables can lead to bugs that are hard to track. Prefer returning new values and keeping data immutable where possible.

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.