JavaScript Functions: Hoisting, Arrow Functions and this, Default Parameters, Closures and Lost Method Context

Key takeaways

JavaScript has several ways to create a function, and they differ in hoisting, in how this is bound and in whether arguments and new work. Most function bugs come from those differences: calling a const function too early, arrow methods that cannot see the object, and methods passed as callbacks that lose this.

Introduction

Functions in JavaScript are values. You can store them in variables, pass them to other functions, return them, and attach them to objects. That flexibility is why callbacks, event handlers and array methods like map feel natural in the language.

The catch is that JavaScript has several syntaxes for creating a function, and they are not interchangeable. They differ in three ways that cause most real bugs:

  • Hoisting: whether you can call the function before the line that defines it.
  • this: whether it is decided by the call site or inherited from the surrounding code.
  • arguments and new: whether the function has them at all.

This article goes through each syntax, then the features built on top (default and rest parameters, closures, higher-order functions), with the actual error messages you will see. Outputs were produced with Node.js 24; browsers print the same errors with slightly different wording in places.


Declarations, expressions and hoisting

Function declarations are hoisted with their body

greet();                       // works
function greet() {
  console.log("Hello!");
}
Hello!

Before running a scope, the engine creates every function declaration in it, body included. That is why the call above works, and why you can order a file with the high-level code at the top and helpers below.

Function expressions follow the rules of their variable

multiply(2, 3);
const multiply = function (a, b) {
  return a * b;
};
ReferenceError: Cannot access 'multiply' before initialization

const and let bindings are hoisted too, but they stay in the temporal dead zone until their line executes. With var the error changes, which is arguably worse:

sub(2, 3);
var sub = function (a, b) {
  return a - b;
};
TypeError: sub is not a function

var sub exists from the start with the value undefined, so you are calling undefined. The var vs let vs const article covers the temporal dead zone in more detail.

A named function expression gives the function a name that is visible only inside itself, which is useful for recursion and for readable stack traces:

const factorial = function fact(n) {
  return n <= 1 ? 1 : n * fact(n - 1);
};
console.log(factorial(5), typeof fact);   // 120 undefined

Which style to use is mostly a team convention. Declarations read well for top-level named functions; const expressions prevent reassignment and force “define before use,” which some teams prefer precisely because it makes the order of a file match the order of execution.


Arrow functions

const add = (a, b) => a + b;           // expression body, implicit return
const square = x => x * x;             // parentheses optional with one parameter
const log = () => console.log("hi");   // no parameters
const makePerson = (name, age) => ({ name, age });  // object literal needs parentheses

The last line hides a common mistake. Without the parentheses, the braces are read as a function body, and name, age is just an expression statement:

const broken = (name, age) => { name, age };
console.log(makePerson("Alice", 25), broken("Alice", 25));
{ name: 'Alice', age: 25 } undefined

Beyond shorter syntax, arrow functions are different in three ways:

No own this. An arrow function uses this from the scope where it was defined (section 5).

No arguments object. Use rest parameters instead:

function showArgs() {
  return arguments.length;
}
const sumAll = (...nums) => nums.reduce((acc, n) => acc + n, 0);

console.log(showArgs(1, 2, 3), sumAll(1, 2, 3));   // 3 6

Rest parameters are a real array (with map, reduce, etc.); arguments is only array-like, so rest is the better choice even in regular functions.

Not constructible.

const Arrow = () => {};
new Arrow();
TypeError: Arrow is not a constructor
DeclarationExpressionArrow
Callable before definitionYesNoNo
thisSet by the callSet by the callFrom enclosing scope
argumentsYesYesNo
newYesYesNo

Parameters: defaults, rest and destructuring

Defaults are evaluated on every call

let counter = 0;
function nextId(id = ++counter) {
  return id;
}
console.log(nextId(), nextId(), nextId(100), nextId());
1 2 100 3

The default expression runs each time the argument is missing, and not at all when it is supplied. That has a pleasant consequence if you come from Python, where a default [] is created once and shared between calls:

function append(item, list = []) {
  list.push(item);
  return list;
}
console.log(append("a"), append("b"));   // [ 'a' ] [ 'b' ]

Each call gets a fresh array. Defaults can also refer to earlier parameters: function range(start, end = start + 10) returns [5, 15] for range(5).

Only undefined triggers a default. null and empty strings are real values:

function greet(name = "guest") {
  return `Hello, ${name}!`;
}
console.log(greet(undefined), greet(null), greet(""));
Hello, guest! Hello, null! Hello, !

This matters when data comes from JSON or a database, where “no value” is usually null. If null should also fall back, use name ?? "guest" inside the function.

Rest and spread

... in a parameter list collects remaining arguments into an array; in a call or literal it spreads an iterable or object out:

function introduce(greeting, ...names) {
  return `${greeting}, ${names.join(", ")}!`;
}

console.log(Math.max(...[3, 9, 1]));        // 9
const base = { a: 1, b: 2 };
const updated = { ...base, b: 3 };          // later keys win
console.log(updated, base);                 // { a: 1, b: 3 } { a: 1, b: 2 }

Object spread is a shallow copy: nested objects are still shared.

Destructuring parameters

For functions with more than two or three options, an options object with destructuring is easier to call correctly than positional arguments:

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

The trailing = {} lets you call createUser() with no argument; without it, destructuring undefined throws TypeError: Cannot destructure property 'name' of 'undefined' as it is undefined.


Higher-order functions

A higher-order function takes a function as an argument or returns one. The array methods are the everyday examples:

const numbers = [1, 2, 3, 4, 5];
const result = numbers
  .filter(x => x % 2 === 0)
  .map(x => x * 2)
  .reduce((acc, x) => acc + x, 0);   // 12

Always pass the initial value to reduce. Without it, reduce on an empty array throws TypeError: Reduce of empty array with no initial value.

Passing an existing function directly (point-free style) is tidy but has a trap: array methods call the callback with three arguments (element, index, array).

console.log(["1", "2", "3"].map(parseInt));
console.log(["1", "2", "3"].map(Number));
[ 1, NaN, NaN ]
[ 1, 2, 3 ]

parseInt treats its second argument as a radix, so it receives parseInt("2", 1) and parseInt("3", 2), both invalid. When in doubt, write the arrow explicitly: .map(s => parseInt(s, 10)).

Returning functions lets you configure behavior once and reuse it:

const makeMultiplier = factor => x => x * factor;
const double = makeMultiplier(2);
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

console.log(pipe(x => x + 1, double)(3));   // 8

How this is decided

For regular functions, this is determined by how the function is called, not where it is written.

Call formthis
obj.method()obj
fn()undefined in strict mode (modules, classes); the global object in sloppy scripts
fn.call(x, ...) / fn.apply(x, [...])x
fn.bind(x)Returns a new function with this fixed to x
new Fn()The newly created object

Arrow functions ignore all of these and use the this of the enclosing scope.

Losing this when passing a method

This is the most common this bug, and it only appears when a method is detached from its object:

class Timer {
  constructor() { this.ticks = 0; }
  tick() { this.ticks++; return this.ticks; }
}

const t = new Timer();
const cb = t.tick;     // what setTimeout(t.tick, 1000) effectively does
cb();
TypeError: Cannot read properties of undefined (reading 'ticks')

t.tick() works because the call has t. in front. cb() does not, and class bodies are strict mode, so this is undefined. The same thing happens with setTimeout(t.tick, 1000), button.addEventListener("click", t.tick) and promise.then(t.tick). Fixes:

setTimeout(() => t.tick(), 1000);        // call it as a method inside an arrow
setTimeout(t.tick.bind(t), 1000);        // or bind once

If you need to remove an event listener later, keep a reference to the bound function (this.onClick = this.onClick.bind(this) in the constructor), because each bind call creates a new function and removeEventListener with a different function silently does nothing.

In non-strict browser scripts the failure is quieter: this becomes window, so this.name reads window.name (usually an empty string) instead of throwing. That silent version has cost me more time than the loud one, because nothing in the console points at the problem; the page just shows an empty label.

Arrow functions inside methods

This is where lexical this helps. A regular function callback inside a method gets its own this; an arrow reuses the method’s:

const team = {
  name: "Core",
  members: ["Ann", "Bo"],
  listRegular() {
    return this.members.map(function (m) { return `${m} @ ${this?.name}`; });
  },
  listArrow() {
    return this.members.map(m => `${m} @ ${this.name}`);
  },
};
console.log(team.listRegular());   // [ 'Ann @ undefined', 'Bo @ undefined' ]
console.log(team.listArrow());     // [ 'Ann @ Core', 'Bo @ Core' ]

Arrow functions as methods (the opposite mistake)

const person = {
  name: "Alice",
  greet: () => console.log(this.name),
};
person.greet();   // never "Alice"

The object literal does not create a scope for this, so the arrow sees the this of the surrounding code. What you get depends on where that code runs: undefined in a Node.js CommonJS file (top-level this is module.exports), TypeError: Cannot read properties of undefined (reading 'name') in an ES module (top-level this is undefined), and window.name in a classic browser script. Use method shorthand: greet() { console.log(this.name); }.

call, apply and bind

function greetWith(greeting, punct) {
  return `${greeting}, ${this.name}${punct}`;
}
greetWith.call({ name: "Alice" }, "Hi", "!");      // "Hi, Alice!"
greetWith.apply({ name: "Bob" }, ["Hey", "."]);    // "Hey, Bob."

These matter less in modern code than they used to (spread replaced most apply uses), but you will see them in libraries.


Closures

A closure is a function together with the variables from the scope where it was created. The function keeps those variables alive after the outer function has returned:

function makeCounter() {
  let count = 0;
  return () => ++count;
}
const c1 = makeCounter();
const c2 = makeCounter();
console.log(c1(), c1(), c2());   // 1 2 1

Each call to makeCounter creates a new count, so c1 and c2 do not share state. Nothing outside can read or modify count directly, which is how closures provide private state without classes.

The loop closure bug: var vs let

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(`var:${i}`), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(`let:${j}`), 0);
}
var:3
var:3
var:3
let:0
let:1
let:2

var creates one i for the whole function. All three callbacks close over it, and by the time they run the loop has finished and i is 3. let creates a new binding per iteration, so each callback captures its own value. Before let existed, the workaround was an IIFE that passed i as a parameter; today, using let is the fix.

Memoization

Closures are also how caches are attached to functions:

function memoize(fn) {
  const cache = new Map();
  return n => {
    if (cache.has(n)) return cache.get(n);
    const result = fn(n);
    cache.set(n, result);
    return result;
  };
}

Map works with any key type; a plain object would convert keys to strings. The cost is memory: this cache never shrinks. I have seen memoized helpers keyed by user input or request data slowly grow a server’s memory until restart, because the closure keeps every entry reachable forever. Cache only functions with a small, bounded set of inputs, or use a size-limited (LRU) cache.


Pure functions

A pure function returns the same output for the same input and does not modify anything outside itself:

let total = 0;
function addImpure(x) { total += x; return total; }
const addPure = (a, b) => a + b;

console.log(addImpure(2), addImpure(2));   // 2 4
console.log(addPure(2, 2), addPure(2, 2)); // 4 4

Pure functions are easy to test (no setup, no mocks), safe to memoize, and safe to call in any order. A subtle source of impurity is mutating an argument. Array.prototype.sort sorts in place, so a helper that sorts its input changes the caller’s array:

function sortedCopy(arr) {
  return [...arr].sort((a, b) => a - b);   // copy first
}
const orig = [3, 1, 2];
console.log(sortedCopy(orig), orig);      // [ 1, 2, 3 ] [ 3, 1, 2 ]

(Modern runtimes also have arr.toSorted(), which returns a sorted copy.) Note the comparator: without it, sort compares elements as strings, so [10, 9, 1].sort() gives [ 1, 10, 9 ].

You cannot make a whole program pure; it has to read input and write output somewhere. The practical goal is to keep the side effects at the edges (event handlers, API calls) and keep the logic in between pure.


Debounce: closures and this together

Debouncing (running a function only after calls stop for a while, such as search-as-you-type) combines most of this article:

function debounce(fn, delay) {
  let timerId;                       // closure: shared between calls
  return function (...args) {        // regular function: receives the caller's this
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), delay);  // arrow: reuses that this
  };
}

const log = debounce(v => console.log("debounced:", v), 20);
log(1); log(2); log(3);   // prints only "debounced: 3"

The returned function is a regular function so that, when used as a method or DOM event handler, it receives the right this; the inner arrow passes that this through to fn. Replacing either one with the other type breaks this forwarding.


Recursion limits

Recursive functions are fine for tree-shaped data, but JavaScript engines have a fixed call stack and do not perform tail-call optimization in practice (only Safari implements it):

function depth(n) { return n <= 0 ? 0 : 1 + depth(n - 1); }
depth(1e6);
RangeError: Maximum call stack size exceeded

For deep or unbounded input (long linked lists, user-provided nesting), rewrite with a loop and an explicit stack.