Skip to main content
JavaScript · lesson 7 of 24
JavaScript beginner Lesson 7 of 24

Functions in JavaScript

Master JavaScript functions — declarations, expressions, arrow functions, default and rest parameters, IIFEs, and first-class function patterns.

By SkillByExample · Updated

Functions are the core building block of JavaScript programs. They let you name a piece of logic, reuse it anywhere, and compose small pieces into larger ones. Understanding the different ways to define and use functions — and how each handles this — will save you from some of the language’s most common bugs.

Function Declarations

A function declaration uses the function keyword followed by a name. It is hoisted to the top of its scope, meaning JavaScript processes the declaration before any code runs. This lets you call the function anywhere in the same file, even above where it is written — which can be useful for keeping the “main” logic at the top of a file and helper functions below.

// Call before definition — works because of hoisting
console.log(add(2, 3)); // 5

function add(a, b) {
  return a + b;
}
$ node funcdecl.js
5

Function declarations are hoisted: the entire function body is moved to the top of the scope before any code executes. So even though add appears on the line below the console.log, it’s available at the point of the call — no ReferenceError. This is different from function expressions, which are not fully hoisted (see the next section).

Function Expressions

A function expression assigns an anonymous (or named) function to a variable. Unlike declarations, function expressions are not hoisted — the variable binding exists but holds undefined (or is in the TDZ for const/let) until the line is reached. Use function expressions when you want to be explicit that the function is a value, when you need to pass it conditionally, or when you want to prevent accidental calls before initialization.

const multiply = function (a, b) {
  return a * b;
};

console.log(multiply(4, 5)); // 20

// Named function expression — the name is useful in stack traces and for recursion
const factorial = function fact(n) {
  return n <= 1 ? 1 : n * fact(n - 1); // `fact` is only visible inside the function
};
console.log(factorial(5)); // 120
$ node funcexpr.js
20
120

multiply is a function expression — the const binding is hoisted but stays in the TDZ until the assignment runs. factorial(5) recursively calls itself: 5*4*3*2*1 = 120. The named function expression function fact(n) gives the function an internal name usable for recursion without polluting the outer scope.

Arrow Functions and this Binding

Arrow functions (=>) are a concise syntax introduced in ES2015. They’re ideal for callbacks and short transformations. Their key behavioral difference from regular functions is that they do not have their own this — they inherit this from the surrounding lexical scope. This solves a very common bug where this inside a callback unexpectedly refers to the wrong object.

// Concise one-liner — no braces needed, value is implicitly returned
const square = x => x * x;
const greet  = name => `Hello, ${name}!`;

// Multi-statement body requires braces and an explicit return
const clamp = (value, min, max) => {
  if (value < min) return min;
  if (value > max) return max;
  return value;
};

console.log(square(6));         // 36
console.log(greet("Alice"));    // Hello, Alice!
console.log(clamp(15, 0, 10));  // 10
$ node arrow-demo.js
36
Hello, Alice!
10

square(6) returns 36 — a single-expression arrow function implicitly returns. greet("Alice") interpolates the name into a template literal. clamp(15, 0, 10) needs braces and an explicit return because it has multiple statements: 15 > 10, so it returns max = 10.

this in regular vs arrow functions

const timer = {
  label: "job",
  startRegular() {
    setTimeout(function () {
      // Regular function creates its own 'this' — loses the object context
      console.log(this.label); // undefined (strict mode) or window.label
    }, 100);
  },
  startArrow() {
    setTimeout(() => {
      // Arrow captures 'this' from startArrow — keeps the object context
      console.log(this.label); // "job"
    }, 100);
  },
};

timer.startArrow(); // "job"

Note on output: The this binding difference between regular functions and arrow functions inside setTimeout requires a browser or full Node DOM environment to demonstrate cleanly. In a Node.js ESM module (where this at module scope is undefined and “use strict” is implicit), the regular function’s this is undefined and the arrow function’s this inherits from the module scope. Running timer.startArrow() logs "job" because the arrow inherits this from startArrow.

Rule of thumb: use arrow functions inside class methods and object methods when you pass callbacks. Use regular functions for the method itself.

Default Parameters

Default parameter values eliminate boilerplate guard code like if (x === undefined) x = defaultValue. They’re evaluated each time the function is called (not once at definition time), which means you can even reference earlier parameters in later defaults.

function createUser(name, role = "viewer", active = true) {
  return { name, role, active };
}

console.log(createUser("Alice"));                    // { name: "Alice", role: "viewer", active: true }
console.log(createUser("Bob", "admin"));             // { name: "Bob",   role: "admin",  active: true }
console.log(createUser("Carol", undefined, false));  // role keeps default "viewer" — undefined triggers the default
$ node defaults.js
{ name: 'Alice', role: 'viewer', active: true }
{ name: 'Bob', role: 'admin', active: true }
{ name: 'Carol', role: 'viewer', active: false }

When you omit an argument or pass undefined explicitly, the default kicks in. createUser("Carol", undefined, false) skips the role argument (defaulting it to "viewer") and provides false for active. Passing null would NOT trigger the default — only undefined does.

Defaults can reference earlier parameters:

function makeRange(start, end = start + 9) {
  return { start, end };
}
console.log(makeRange(1));    // { start: 1, end: 10 }
console.log(makeRange(5, 8)); // { start: 5, end: 8  }
$ node default-ref.js
{ start: 1, end: 10 }
{ start: 5, end: 8 }

Default parameters can reference earlier ones: makeRange(1) computes end = 1 + 9 = 10. When end is explicitly provided (makeRange(5, 8)), the default isn’t used. This is useful for functions where the second argument’s default depends on the first.

Rest Parameters

Rest parameters collect all remaining arguments into a real array, giving you a clean way to write functions that accept any number of inputs. They replace the old arguments object, which was array-like but not a real array and didn’t work in arrow functions at all. Only one rest parameter is allowed per function, and it must be the last parameter.

function sum(...numbers) {
  // `numbers` is a genuine Array — all array methods work on it
  return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3, 4, 5)); // 15

function logWithPrefix(prefix, ...messages) {
  messages.forEach(msg => console.log(`[${prefix}] ${msg}`));
}
logWithPrefix("INFO", "Server started", "Listening on port 3000");
// [INFO] Server started
// [INFO] Listening on port 3000
$ node rest-params.js
15
[INFO] Server started
[INFO] Listening on port 3000

Rest parameters bundle all extra arguments into a real Array. sum(1, 2, 3, 4, 5) reduces them to 15. logWithPrefix("INFO", ...) keeps the first argument as prefix and collects the rest into messages — the spread of “rest” (collect) vs “spread” (unpack) is a key mental distinction.

Spread in Function Calls

The spread operator (...) is the mirror image of rest parameters: instead of collecting arguments into an array, it unpacks an array into individual arguments. This is useful when you have data in an array but need to pass it to a function that expects separate arguments.

const nums = [3, 1, 4, 1, 5, 9, 2, 6];

// Math.max expects individual arguments — spread unpacks the array
console.log(Math.max(...nums)); // 9

function formatDate(year, month, day) {
  return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}

const parts = [2024, 7, 4];
console.log(formatDate(...parts)); // "2024-07-04"
$ node spread-call.js
9
2024-07-04

Math.max(...nums) expands the array into separate arguments — equivalent to calling Math.max(3, 1, 4, 1, 5, 9, 2, 6), which returns 9. formatDate(...parts) unpacks [2024, 7, 4] into three positional arguments, formatting them into an ISO-style date string.

IIFE — Immediately Invoked Function Expression

An IIFE is a function that defines and calls itself in a single expression. Before ES modules and block-scoped variables existed, IIFEs were the standard way to create a private scope and avoid polluting the global namespace. You still encounter them in legacy code, and they remain useful for running async logic at the top level in environments that don’t support top-level await.

// Classic IIFE — creates its own scope, variables don't leak out
const result = (function () {
  const secret = 42; // not accessible outside
  return secret * 2;
})();

console.log(result); // 84
// console.log(secret); // ReferenceError — secret is contained

// Async IIFE — run top-level async code in older environments
(async () => {
  const data = await fetch("/api/config").then(r => r.json());
  console.log(data);
})();
$ node iife.js
84

The IIFE (function() { ... })() runs immediately and returns 84 — secret is private inside the function’s scope. The async variant (async () => { ... })() would fetch configuration — in a browser it would log the parsed JSON. The synchronous portion returns 84 as shown.

Pure Functions

A pure function always returns the same output for the same inputs and causes no side effects — it doesn’t modify external state, write to the DOM, make network requests, or change its arguments. Pure functions are the easiest to test because they have no hidden dependencies, and the easiest to reason about because their behavior is entirely described by their inputs and output.

// Pure — deterministic, no side effects
function formatPrice(amount, currency = "USD") {
  return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
console.log(formatPrice(1999.9));       // "$1,999.90"
console.log(formatPrice(49.99, "EUR")); // "€49.99"

// Impure — depends on external state, result changes without changing arguments
let taxRate = 0.08;
function calcTotal(price) {
  return price + price * taxRate; // reading external variable = side cause
}
$ node pure.js
$1,999.90
€49.99

formatPrice is pure: same inputs always yield the same outputs, no side effects. Intl.NumberFormat formats 1999.9 with currency symbol and thousands separator. The calcTotal function is impure — its result depends on taxRate which could change, meaning the same input price produces different outputs across calls.

First-Class Functions

In JavaScript, functions are values just like numbers or strings. This is called “first-class” status: you can store functions in variables, pass them as arguments, return them from other functions, and put them in arrays or objects. This capability is the foundation for higher-order functions, callbacks, and every functional programming pattern in JavaScript.

// Store in a variable (already seen)
const double = x => x * 2;

// Store in an object (method)
const math = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
};

// Pass as argument — `applyTwice` is a higher-order function
function applyTwice(fn, value) {
  return fn(fn(value));
}
console.log(applyTwice(double, 3)); // 12

// Return from a function — creates a closure (factory pattern)
function multiplier(factor) {
  return value => value * factor; // `factor` is captured in the closure
}
const triple = multiplier(3);
const byTen  = multiplier(10);
console.log(triple(7)); // 21
console.log(byTen(5));  // 50

// Real-world: compose a pipeline of transformations
const pipeline = (...fns) => value => fns.reduce((v, fn) => fn(v), value);

const processInput = pipeline(
  s => s.trim(),
  s => s.toLowerCase(),
  s => s.replace(/\s+/g, "-"),
);
console.log(processInput("  Hello World  ")); // "hello-world"
$ node first-class.js
12
21
50
hello-world

applyTwice(double, 3) calls double(double(3)) = double(6) = 12. The multiplier factory captures factor in a closure — triple permanently remembers factor = 3. The pipeline composes three transformations: trim, lowercase, then replace whitespace with hyphens, turning " Hello World " into "hello-world".

Common Pitfalls

Forgetting to return in an arrow function with braces:

// Bug: braces require an explicit return statement
const double = x => { x * 2 }; // returns undefined!

// Fix 1: remove braces for a single expression (implicit return)
const double1 = x => x * 2;

// Fix 2: add explicit return inside braces
const double2 = x => { return x * 2; };

Using arrow functions as object methods:

const counter = {
  count: 0,
  // Wrong: arrow captures 'this' from the outer scope (likely window/undefined)
  increment: () => { this.count++; },
  // Correct: method shorthand gets its own 'this' bound to the object
  decrement() { this.count--; },
};

arguments object doesn’t exist in arrow functions:

function oldStyle() {
  console.log(arguments[0]); // works — arguments is available
}

const newStyle = () => {
  console.log(arguments[0]); // ReferenceError in strict mode
};

// Use rest parameters in arrow functions instead
const newStyleFixed = (...args) => console.log(args[0]);

Practice

  1. Function composition: Write a pipe(...fns) function that takes functions and returns a new function that pipes its input through them left-to-right. Then create add2, double, and subtract3 functions and compose them so pipe(add2, double, subtract3)(5) returns 9.

  2. Once function: Write a once(fn) wrapper that allows a function to be called exactly once. All subsequent calls return the cached result of the first call, and the original fn is never invoked again.

  3. Default parameter edge case: Write a function greet(name = "Guest", time = "day") that returns "Good morning, Alice!" style messages. What happens when you call greet("Bob", null)? Fix it so null also uses the default.

  4. Arrow this rescue: Convert this object method to use an arrow function safely, or fix the arrow function to bind this correctly:

    const tracker = {
      count: 0,
      tick: () => { this.count++; }
    };
Solution 1 ```js function pipe(...fns) { return value => fns.reduce((v, fn) => fn(v), value); } const add2 = x => x + 2; const double = x => x * 2; const subtract3 = x => x - 3;

const compute = pipe(add2, double, subtract3); console.log(compute(5)); // 9

</details>

<details><summary>Solution 2</summary>
```js
function once(fn) {
  let called = false;
  let result;
  return function(...args) {
    if (!called) {
      result = fn.apply(this, args);
      called = true;
    }
    return result;
  };
}
const initialize = once(() => Math.random());
console.log(initialize()); // e.g. 0.342...
console.log(initialize()); // same value
Solution 3 ```js function greet(name = "Guest", time = "day") { return `Good ${time}, ${name}!`; } // greet("Bob", null) returns "Good null, Bob!" — null doesn't trigger defaults // Fix: use undefined explicitly, or wrap with ?? const greetFixed = (name, time) => greet(name ?? "Guest", time ?? "day"); ```
Solution 4 ```js // Fix 1: use method shorthand const tracker = { count: 0, tick() { this.count++; } }; tracker.tick(); console.log(tracker.count); // 1

// Fix 2: or bind the arrow function const tracker2 = { count: 0, tick: (function() { this.count++; }).bind(this) // but this is awkward };

</details>

Frequently Asked Questions

What is the difference between a function declaration and a function expression?
A function declaration is hoisted — you can call it before it appears in the file. A function expression (assigned to a variable) is not hoisted; calling it before the assignment throws a ReferenceError (let/const) or returns undefined (var).
When should I use an arrow function instead of a regular function?
Use arrow functions for callbacks, array methods, and any short anonymous function. Use regular functions when you need your own 'this' binding — e.g. object methods, event handlers that reference the element, or constructor functions.
What does 'first-class function' mean?
It means functions are values. You can assign them to variables, pass them as arguments, return them from other functions, and store them in arrays or objects — the same as any other value like a string or number.