JavaScript Functions

A function in JavaScript is a reusable block of code built to perform a specific task. It can accept input values called parameters, run some logic, and optionally hand back a result with the return keyword. Functions are the fundamental unit of reuse and abstraction in JavaScript — instead of duplicating logic everywhere it is needed, you define it once and call it whenever necessary. Because functions in JavaScript are also full-fledged values, not just named blocks of code, they can be stored, passed around, and composed in ways that unlock patterns like callbacks, closures, and higher-order functions.

Overview: How Functions Work

Every function goes through two phases: definition (creating the function) and invocation (calling it). At definition time, the engine parses the parameter list and body and creates a function object. That object is a value like any other — it has type "function", can carry its own properties, and holds a hidden reference to the lexical scope in which it was created. That hidden reference is exactly what makes closures possible.

There are three common ways to create a function: a function declaration (function name() {}), a function expression (const name = function() {}), and an arrow function (const name = () => {}). All three produce callable function objects, but they differ in how they are hoisted, whether they get their own this and arguments, and whether they can be used as constructors with new.

When you call a function, the engine pushes a new execution context onto the call stack. That context has its own variable environment (where parameters and local let/const/var declarations live), a link to the outer scope (forming the scope chain used to resolve variables that are not local), and a this binding. When the function returns — either by hitting a return statement or reaching the end of its body — its context is popped off the stack and control resumes where the call happened. Nested calls push more contexts on top, which is why very deep, unterminated recursion eventually throws RangeError: Maximum call stack size exceeded.

A closely related idea is the closure: because a function keeps a live reference to the scope it was defined in, rather than a frozen snapshot of values, it can keep reading and updating variables from that outer scope even after the outer function has finished running and its execution context is long gone. This is how a function can “remember” private state, which Example 3 below demonstrates directly.

Syntax

The general forms are shown below, alongside default and rest parameters — two ES6+ features that make parameter handling much cleaner than manually checking for undefined or using the old arguments object.

// Function declaration
function calculateTotal(price, quantity) {
  return price * quantity;
}

// Function expression
const calculateTax = function (amount, rate) {
  return amount * rate;
};

// Arrow function
const calculateDiscount = (amount, percent) => {
  return amount * (percent / 100);
};

// Arrow function with implicit return
const double = n => n * 2;
Piece Meaning
function name(...) {} A function declaration — hoisted with its full body, so it can be called before its line in the source.
const name = function(...) {} A function expression — the variable is hoisted, but is not assigned a function value until this line executes.
const name = (...) => {} An arrow function — concise syntax, no own this or arguments, cannot be used with new.
param = defaultValue A default parameter, used automatically when the caller omits the argument or passes undefined.
...rest A rest parameter that collects any remaining arguments into a real array.
return value; Ends the function and hands value back to the caller. Without an explicit return, a function returns undefined.

Examples

Example 1: Three Ways to Write the Same Function

The same calculation can be written as a declaration, an expression, or an arrow function. They behave identically when called.

function areaDeclaration(width, height) {
  return width * height;
}

const areaExpression = function (width, height) {
  return width * height;
};

const areaArrow = (width, height) => width * height;

console.log(areaDeclaration(5, 3));
console.log(areaExpression(5, 3));
console.log(areaArrow(5, 3));

Output:

15
15
15

All three forms compute width * height and return it. The only real differences are stylistic and structural: areaDeclaration is hoisted entirely, so it could be called before this line; areaExpression and areaArrow live on const bindings, so they only exist once the assignment line runs.

Example 2: Default and Rest Parameters

Default parameters replace manual if (x === undefined) checks, and rest parameters replace the old, array-like arguments object with a real array.

function greet(name = "friend", greeting = "Hello") {
  return `${greeting}, ${name}!`;
}

function sum(...numbers) {
  return numbers.reduce((total, n) => total + n, 0);
}

console.log(greet());
console.log(greet("Maria"));
console.log(greet("Sam", "Hey"));
console.log(sum(1, 2, 3, 4));

Output:

Hello, friend!
Hello, Maria!
Hey, Sam!
10

greet() falls back to both defaults; greet("Maria") only overrides name; and sum(...numbers) gathers every argument into a real array, which is why .reduce() works on it directly — the old arguments object is not a true array and has no .reduce() method.

Example 3: Closures — Private State With a Factory Function

A closure lets a returned set of functions keep private access to a variable that outside code can never touch directly.

function createAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) {
        throw new Error("Insufficient funds");
      }
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createAccount(100);
console.log(account.deposit(50));
console.log(account.withdraw(30));
console.log(account.getBalance());

Output:

150
120
120

balance is a local variable inside createAccount, but the three methods returned from it all close over that same variable. There is no other way to read or change balance from outside — it is effectively a private field, decades before JavaScript had real private class fields.

Under the Hood: What Happens When You Call a Function

Hoisting works differently depending on how you wrote the function. A function declaration is hoisted completely: during the compile/parse phase the engine registers both the name and the full function body, so it is safe to call it earlier in the file. A function expression or arrow function assigned to let/const is different: the variable name is hoisted, but it stays in the “temporal dead zone” with no value until the assignment line actually executes; calling it earlier throws a ReferenceError. With var, the variable would instead be undefined until that line, giving a TypeError: ... is not a function if called too early.

Walking through Example 3: calling createAccount(100) pushes a new execution context with its own variable environment containing initialBalance and balance. The returned object’s three methods are created inside that same context, so each one stores a hidden link back to it. Once createAccount returns, its context is popped off the call stack — but because the three methods still reference that variable environment, the engine cannot garbage-collect it. balance stays alive in memory for as long as account (or anything holding one of its methods) exists. Each subsequent call, like account.deposit(50), pushes its own short-lived execution context, reads and writes the shared balance through the scope chain, and pops off again when it returns.

Common Mistakes

Mistake 1: Expecting arguments Inside an Arrow Function

Arrow functions do not get their own arguments object. Referencing arguments inside one resolves to the arguments of the nearest enclosing regular function — which is almost never what you want.

function processOrder(a, b, c) {
  const sumAll = () => {
    let total = 0;
    for (let i = 0; i < arguments.length; i++) {
      total += arguments[i];
    }
    return total;
  };
  return sumAll(10, 20, 30);
}

console.log(processOrder(1, 2, 3));

Output:

6

Even though sumAll is called with (10, 20, 30), those values are discarded. The arrow function has no arguments of its own, so arguments inside it resolves to processOrder‘s own arguments, [1, 2, 3], giving 6 instead of the expected 60. The fix is to use a rest parameter, which an arrow function can have of its own:

function processOrder(a, b, c) {
  const sumAll = (...nums) => nums.reduce((total, n) => total + n, 0);
  return sumAll(10, 20, 30);
}

console.log(processOrder(1, 2, 3));

Output:

60

Mistake 2: Using an Arrow Function as an Object Method

Arrow functions capture this lexically from where they are defined, not from how they are called. That makes them a poor fit for methods that need this to point at the object they belong to.

function makeCounter() {
  return {
    count: 0,
    increment: () => {
      this.count++;
      return this.count;
    }
  };
}

const counter = makeCounter();
console.log(counter.increment());

Output:

NaN

increment is an arrow function, so its this is whatever this was inside makeCounter at the moment the object literal was created — not counter. Since makeCounter() was called as a plain function, this there is the global object, so this.count++ increments a stray global count property (starting from undefined, which coerces to NaN). Using a regular method instead fixes it, because regular methods get this bound dynamically to whatever object they are called on:

function makeCounter() {
  return {
    count: 0,
    increment() {
      this.count++;
      return this.count;
    }
  };
}

const counter = makeCounter();
console.log(counter.increment());

Output:

1

Mistake 3: Calling a Function Instead of Passing a Reference

A very common typo is adding parentheses where a callback reference is expected, for example writing setTimeout(sayHi(), 1000) instead of setTimeout(sayHi, 1000). The first form calls sayHi immediately and passes whatever it returns (usually undefined) as the callback, so nothing useful happens after the delay. The second form passes the function itself, so setTimeout can call it later, once the timer elapses. The same mistake shows up with array methods too, such as writing numbers.map(square()) instead of numbers.map(square).

Best Practices

  • Prefer const for function expressions and arrow functions so the binding cannot be accidentally reassigned.
  • Use arrow functions for short callbacks and anywhere you want this to stay bound to the surrounding scope; use regular functions (or method shorthand) for object methods and anything relying on dynamic this.
  • Prefer default parameters over manual undefined checks, and rest parameters over the legacy arguments object.
  • Keep functions small and focused on one responsibility — if a function’s name needs “and” to describe it, split it.
  • Give function expressions names where practical (const add = function add(a, b) {...}) so stack traces are easier to read while debugging.
  • Avoid mutating variables outside a function’s own scope; prefer returning new values so functions stay predictable and easy to test.
  • Use early return statements to avoid deeply nested if blocks.

Practice Exercises

  • Write a function isEven(n) that returns true if a number is even and false otherwise. Then rewrite it as an arrow function with an implicit return.
  • Write a factory function createMultiplier(factor) that returns a new function which multiplies any number passed to it by factor. For example, createMultiplier(3)(7) should return 21. This exercises closures directly.
  • Write a function formatName(first, last, middle = "") that returns a full name string, then a second version joinWords(separator, ...words) that joins any number of words using a rest parameter and the given separator.

Summary

  • Functions are reusable, callable blocks of code that can take parameters and return a value; in JavaScript they are also first-class values that can be stored, passed, and returned.
  • Function declarations are fully hoisted; function expressions and arrow functions are not usable until their assignment line runs.
  • Arrow functions have no own this or arguments — they inherit both lexically from the nearest enclosing regular function, which makes them great for callbacks but risky for object methods.
  • Default parameters and rest parameters (...args) are the modern, cleaner replacements for manual undefined checks and the legacy arguments object.
  • A closure lets a function keep access to variables from its defining scope even after that outer function has returned, which is the mechanism behind private state and many functional patterns.
  • Each function call pushes a new execution context onto the call stack; the context is popped when the function returns.