JavaScript · lesson 7 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Functions 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
thisbinding difference between regular functions and arrow functions insidesetTimeoutrequires a browser or full Node DOM environment to demonstrate cleanly. In a Node.js ESM module (wherethisat module scope isundefinedand “use strict” is implicit), the regular function’sthisisundefinedand the arrow function’sthisinherits from the module scope. Runningtimer.startArrow()logs"job"because the arrow inheritsthisfromstartArrow.
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
-
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 createadd2,double, andsubtract3functions and compose them sopipe(add2, double, subtract3)(5)returns9. -
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 originalfnis never invoked again. -
Default parameter edge case: Write a function
greet(name = "Guest", time = "day")that returns"Good morning, Alice!"style messages. What happens when you callgreet("Bob", null)? Fix it sonullalso uses the default. -
Arrow
thisrescue: Convert this object method to use an arrow function safely, or fix the arrow function to bindthiscorrectly: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>