JavaScript Functions, Scope, and Closures


  • Description: Function declarations vs expressions vs arrows, parameters and defaults, rest/spread, scope rules, hoisting, and closures — including why every list/loop trap eventually leads here.
  • My Notion Note ID: K2A-F4-4
  • Created: 2018-03-23
  • Updated: 2026-05-17
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Three Ways to Define a Function

// Declaration — hoisted, has its own this/arguments
function greet(name) { return `Hi ${name}`; }

// Expression — assigned to a variable, anonymous or named
const greet = function (name) { return `Hi ${name}`; };
const greet = function greet(name) { return `Hi ${name}`; };   // named for stack traces

// Arrow — lexical this, no arguments, no super, can't be constructor
const greet = (name) => `Hi ${name}`;
const greet = name => `Hi ${name}`;                       // one param, no parens
const compute = (a, b) => {
  const sum = a + b;
  return sum * 2;                                          // multi-line: braces + return
};
const make = () => ({ name: 'x' });                       // returning object literal needs parens

Differences worth remembering:

Feature Declaration Expression Arrow
Hoisted Yes (whole function) Just the variable binding Just the variable
Has own this Yes Yes No — inherited from enclosing scope
Has own arguments Yes Yes No
Can be new'd Yes Yes No (TypeError)
Implicit return No No Yes if no body braces
.name property Function name Variable name if anon Variable name

Pick arrow for callbacks and short helpers; pick declaration for top-level / named functions you might recurse into; rarely use a named expression unless you specifically want a different external/internal name.

2. Parameters

2.1 Defaults

function greet(name = 'world', loud = false) {
  return loud ? `HI ${name.toUpperCase()}` : `Hi ${name}`;
}
greet();              // 'Hi world'
greet(undefined);     // 'Hi world' — undefined triggers default; null doesn't
greet('Yu', true);    // 'HI YU'

Defaults are evaluated per call in scope of the parameter list:

function f(x, y = x * 2) { return y; }
f(3);    // 6

2.2 Rest Parameters

Collect remaining args into an array:

function sum(...nums) { return nums.reduce((a, b) => a + b, 0); }
sum(1, 2, 3, 4);      // 10

function tag(strings, ...values) { /* template tag */ }

Rest must be the last parameter.

2.3 Spread (the call-site complement)

const args = [1, 2, 3];
sum(...args);          // sum(1, 2, 3)

[...arr1, ...arr2];     // concatenate
{ ...obj1, ...obj2 };   // merge (later wins on conflict)

Spread also works in array literals, object literals, and new expressions.

2.4 Destructuring Parameters

function draw({ x = 0, y = 0, color = 'black' } = {}) {
  // ...
}
draw({ x: 10, color: 'red' });
draw();                // works because of the `= {}` default

= {} is essential — without it, calling draw() would destructure undefined and throw.

Array destructuring works too:

function swap([a, b]) { return [b, a]; }
swap([1, 2]);          // [2, 1]

2.5 The Legacy arguments Object

Inside non-arrow functions only:

function f() {
  console.log(arguments);        // [Arguments] { 0: …, 1: … }
  console.log(arguments.length);
  console.log([...arguments]);   // convert to real array
}

Array-like but not an array — needs spread or Array.from to use array methods. Mostly obsolete; rest parameters are clearer.

3. Calling Functions

greet('Yu');                               // direct
const fn = greet; fn('Yu');                 // through a variable
arr.map(greet);                              // as a callback
greet.call(thisArg, 'Yu');                   // explicit this + args list
greet.apply(thisArg, ['Yu']);                // explicit this + args array
const bound = greet.bind(thisArg, 'Yu');     // returns new function with bound this/args
bound();                                      // calls greet.call(thisArg, 'Yu')
new Greeter('Yu');                            // constructor call

.bind() is one-shot — re-binding a bound function doesn't change the original binding.

3.1 IIFE (Immediately Invoked Function Expression)

(function () {
  // private scope
})();

(() => {
  // arrow version
})();

Once essential for module-style encapsulation. ES modules replaced 95% of the use cases.

4. this Binding

this is determined by how a function is called, not where it's defined — except for arrow functions, which freeze this at definition.

Call form this
fn() (plain) undefined in strict mode; globalThis otherwise
obj.fn() obj
fn.call(x, …) / fn.apply(x, …) x
new Fn(…) newly created object
Arrow function inherited from enclosing scope (locked at definition)
Event handler (DOM) the element
fn.bind(x)() x
const obj = {
  name: 'A',
  greet() { return `Hi ${this.name}`; },
  greetArrow: () => `Hi ${this.name}`,
};

obj.greet();             // 'Hi A'
const g = obj.greet;
g();                      // 'Hi undefined' — lost binding
obj.greetArrow();         // arrow's this was captured at definition, not from the call:
                          //   in an ES module → TypeError (top-level this is undefined)
                          //   in a browser classic script → 'Hi ' (window.name defaults to '')

Common bug — passing a method as callback loses this:

button.addEventListener('click', obj.greet);    // 'this' will be the button
button.addEventListener('click', () => obj.greet());   // works — arrow captures obj
button.addEventListener('click', obj.greet.bind(obj)); // works — explicit bind

Class methods are not auto-bound — same pitfall. Class field arrow methods are auto-bound:

class Counter {
  count = 0;
  incrementMethod() { this.count++; }           // not bound to instance
  incrementArrow = () => { this.count++; };     // bound — arrow captures `this`
}

The auto-bound pattern is heavier (per-instance function), but solves the React onClick={this.handler} issue.

5. Scope

JavaScript has three scope levels:

Scope Bound by Created by
Global the file / module / page top-level code
Function function body function keyword
Block { … } any block (also if, for, try, …)
function outer() {       // function scope
  let a = 1;
  if (true) {            // block scope
    let b = 2;
    const c = 3;
    var d = 4;           // var leaks to function scope
  }
  console.log(a);        // 1
  // console.log(b);     // ReferenceError
  console.log(d);         // 4 — var ignored block
}

let and const are block-scoped. var is function-scoped (or global). var leaks out of blocks — main reason to avoid it.

Lexical scope — inner scopes can see outer scopes' bindings; outer can't see inner. Resolution walks outward at runtime through the scope chain.

const a = 1;
function outer() {
  const b = 2;
  function inner() {
    const c = 3;
    return a + b + c;    // 1 + 2 + 3 = 6
  }
  return inner();
}

6. Hoisting

JavaScript "moves" certain declarations to the top of their scope before execution. Mental model: compilation registers all bindings before any statement runs.

Construct Hoisting
var x = … Declaration hoisted, initialised to undefined. Assignment stays.
let x = … / const x = … Binding hoisted; access throws ReferenceError (TDZ) until declaration line.
function f() {} Whole function hoisted. Callable before its source line.
class C {} Hoisted but TDZ — can't reference before declaration.
Function expression / arrow Only the variable hoists; function value comes at assignment time.
console.log(a);    // undefined  — var hoisted, no value yet
var a = 1;

console.log(b);    // ReferenceError (TDZ)
let b = 1;

hello();           // works — function declaration hoists with body
function hello() { console.log('hi'); }

bye();             // TypeError — bye is undefined, not a function (yet)
var bye = function () { console.log('bye'); };

Modern code rarely depends on hoisting beyond function declarations. let/const make the TDZ a feature — using a variable before it's declared is almost always a bug.

7. Closures

A closure is a function plus the lexical environment it was defined in. The function keeps access to those variables even after the outer function returns.

function makeCounter() {
  let count = 0;
  return {
    increment() { count++; },
    get()        { return count; },
  };
}

const c = makeCounter();
c.increment();
c.increment();
c.get();              // 2

count lives as long as the returned methods do. This is the basic "private state" pattern in JS.

7.1 The Classic Loop Trap

// Old, broken with var
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Logs: 3, 3, 3 — all share the same var i

// Fixed with let — each iteration creates a fresh binding
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Logs: 0, 1, 2

let in the for head allocates a new binding per iteration, so each callback captures a different i. One of the headline reasons to drop var.

7.2 Common Closure Uses

  • Private state — counters, caches, configuration.
  • Currying / partial applicationconst add5 = x => y => x + y;.
  • Memoization — wrap a function with a cache it owns:
function memoize(fn) {
  const cache = new Map();
  return (key) => {
    if (cache.has(key)) return cache.get(key);
    const result = fn(key);
    cache.set(key, result);
    return result;
  };
}
  • Module pattern (pre-ESM) — IIFE that exposes a small API while keeping internals hidden.
  • Event handlersonClick callbacks that need to remember which row they belong to.

7.3 Memory Implications

A closure holds onto its lexical environment. If a closure references a large object you don't actually use, the GC can't free it. Audit closures in long-lived code (event listeners, intervals, caches) to make sure they don't pin unnecessary state.

8. Higher-Order Functions

Functions that take other functions, return other functions, or both. Most array methods are higher-order.

// Take a function
arr.map(x => x * 2);

// Return a function
const greaterThan = (n) => (x) => x > n;
arr.filter(greaterThan(10));

// Composition
const compose = (...fns) => (x) => fns.reduceRight((v, fn) => fn(v), x);
const trim    = s => s.trim();
const upper   = s => s.toUpperCase();
const shout   = compose(upper, trim);
shout('  hi  ');     // 'HI'

Functional helpers are first-class in JS — most "library missing" complaints in 2026 are unwarranted; the language has the primitives.

9. References