Skip to content

Functions and Scope

Functions are the core abstraction in JavaScript. They are ordinary values you can pass around, they capture the variables around them (closures), and the value of this inside them depends on how they are called — the one part of the language that is not lexical.

function add(a, b) {
return a + b;
}

Fully hoisted — the entire function is available before its line runs, anywhere in the enclosing scope.

greet(); // => 'hi' — works
function greet() { return 'hi'; }

A function used as a value. Only the variable binding is hoisted, so it cannot be called early.

const add = function (a, b) {
return a + b;
};
const fact = function factorial(n) { // named expression
return n <= 1 ? 1 : n * factorial(n - 1);
};
// `factorial` is visible inside the function only — useful for recursion and stack traces

Shorter syntax, and crucially no own this, arguments, super, or new.target.

const add = (a, b) => a + b; // implicit return
const square = (n) => n * n;
const noop = () => {}; // empty body
const make = () => ({ ok: true }); // object literal needs parentheses
const long = (a, b) => {
const sum = a + b;
return sum * 2; // block body needs explicit return
};

Arrows cannot be used as constructors (new throws) and have no prototype property.

Declaration Expression Arrow
Hoisted fully binding only binding only
Own this yes yes no (inherits lexically)
arguments object yes yes no
Usable with new yes yes no
Has prototype yes yes no
Can be a generator yes yes no

Default values fire when the argument is undefined — not when it is null, 0, or ''.

function connect(host, port = 443, opts = {}) {
return `${host}:${port}`;
}
connect('example.com'); // => 'example.com:443'
connect('example.com', undefined); // => 'example.com:443'
connect('example.com', null); // => 'example.com:null'

Defaults are evaluated at call time, left to right, so a later default can reference an earlier parameter:

function slice(arr, start = 0, end = arr.length) {
return arr.slice(start, end);
}

That also means a fresh object is created per call — which is why opts = {} is safe, unlike a shared module-level default.

...rest collects the remaining arguments into a real array. It must be last, and there can be only one.

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

Every non-arrow function has arguments: an array-like object of all arguments passed, regardless of the declared parameters. It has length and indices but no array methods.

function legacy() {
console.log(arguments.length); // => 3
console.log(arguments[0]); // => 'a'
console.log(Array.from(arguments)); // => ['a', 'b', 'c']
}
legacy('a', 'b', 'c');
function f(a, b, c = 1, ...rest) {}
f.length; // => 2 (params before the first default or rest)
f.name; // => 'f'

Missing arguments are undefined; extra arguments are ignored (but still visible in arguments).

Functions are values. You can store them, pass them, return them, and put them in data structures.

const ops = {
add: (a, b) => a + b,
mul: (a, b) => a * b,
};
ops.add(2, 3); // => 5

A higher-order function takes a function, returns a function, or both. map, filter, setTimeout, and addEventListener are all higher-order.

// Takes a function
const twice = (fn, x) => fn(fn(x));
twice((n) => n + 3, 1); // => 7
// Returns a function
const multiplyBy = (factor) => (n) => n * factor;
const double = multiplyBy(2);
double(21); // => 42

A practical composition helper:

const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
const slugify = pipe(
(s) => s.trim(),
(s) => s.toLowerCase(),
(s) => s.replace(/[^a-z0-9]+/g, '-'),
(s) => s.replace(/^-|-$/g, ''),
);
slugify(' Hello, World! '); // => 'hello-world'

Lexical (or static) scope means an identifier resolves based on where the code is written, not where it is called. Nested functions can read the variables of every enclosing scope, up to the global scope.

const outerVar = 'outer';
function outer() {
const middleVar = 'middle';
function inner() {
console.log(outerVar, middleVar); // both visible
}
inner();
}

The chain is searched innermost first. A local name shadows an outer one:

let x = 'global';
function f() {
let x = 'local';
console.log(x); // => 'local'
}
f();
console.log(x); // => 'global'

Where the function is called makes no difference:

const message = 'module';
function show() { console.log(message); }
function caller() {
const message = 'caller';
show(); // => 'module' — lexical, not dynamic
}
caller();

A closure is a function together with the scope it was created in. When a function outlives the scope that created it, that scope stays alive — the variables are not destroyed.

function makeCounter() {
let count = 0; // private to this closure
return {
increment: () => ++count,
get: () => count,
};
}
const c = makeCounter();
c.increment(); // => 1
c.increment(); // => 2
c.get(); // => 2
c.count; // => undefined — genuinely inaccessible

Each call to makeCounter creates a new, independent scope:

const a = makeCounter();
const b = makeCounter();
a.increment(); a.increment();
b.increment();
a.get(); // => 2
b.get(); // => 1

Cache results of a pure function in a closed-over Map.

function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
let calls = 0;
const slowSquare = memoize((n) => { calls++; return n * n; });
slowSquare(4); // => 16
slowSquare(4); // => 16
calls; // => 1
function once(fn) {
let called = false;
let result;
return (...args) => {
if (!called) {
called = true;
result = fn(...args);
}
return result;
};
}
const init = once(() => { console.log('initializing'); return 'ready'; });
init(); // logs, => 'ready'
init(); // => 'ready', no log

The canonical closure use in UI code — delay work until input stops.

function debounce(fn, ms) {
let timer; // survives between calls
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const search = debounce((q) => console.log('searching', q), 300);
search('a'); search('ab'); search('abc');
// => only 'searching abc' fires, 300 ms after the last call

Every closure created in a var loop shares one binding:

const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
fns.map((f) => f()); // => [3, 3, 3]
const fns2 = [];
for (let j = 0; j < 3; j++) fns2.push(() => j);
fns2.map((f) => f()); // => [0, 1, 2]

let gives each iteration a fresh binding, which is the whole fix.

Inside a normal function, this is decided when the function is called, by exactly one of four rules. Check them in this order (highest priority first).

Calling with new creates a fresh object and binds this to it.

function User(name) {
this.name = name; // this is the new object
}
const u = new User('Ada');
u.name; // => 'Ada'
function describe(greeting) {
return `${greeting}, ${this.name}`;
}
const person = { name: 'Ada' };
describe.call(person, 'Hello'); // => 'Hello, Ada' (args listed)
describe.apply(person, ['Hi']); // => 'Hi, Ada' (args in an array)
const bound = describe.bind(person);
bound('Hey'); // => 'Hey, Ada' (returns a new function)

bind returns a new function permanently tied to that this; it cannot be rebound. It can also pre-fill arguments (partial application):

const shout = describe.bind(person, 'HELLO');
shout(); // => 'HELLO, Ada'

When a function is called as obj.method(), this is obj. Only the call site matters, not where the function was defined.

const counter = {
count: 0,
increment() {
this.count++;
return this.count;
},
};
counter.increment(); // => 1, this is counter

The lost-this trap. Detaching a method drops the binding:

const inc = counter.increment;
inc(); // TypeError in strict mode (this is undefined)
setTimeout(counter.increment, 100); // broken
setTimeout(() => counter.increment(), 100); // fixed: real method call
setTimeout(counter.increment.bind(counter), 100); // also fixed

A plain call with none of the above. In strict mode (and therefore in all ES modules and class bodies) this is undefined. In sloppy mode it is the global object (window / globalThis).

'use strict';
function loose() { return this; }
loose(); // => undefined

An arrow has no this of its own; it uses the this of the enclosing scope at the moment it was defined. call, apply, bind, and new cannot change it.

const timer = {
seconds: 0,
startBroken() {
setInterval(function () {
this.seconds++; // `this` is not `timer` — plain call
}, 1000);
},
startWorking() {
setInterval(() => {
this.seconds++; // arrow inherits `this` from startWorking
}, 1000);
},
};

Quick decision table:

Call form this
new Fn() the new object
fn.call(o) / fn.apply(o) / fn.bind(o)() o
obj.fn() obj
fn() undefined (strict) or global (sloppy)
arrow function this of the enclosing lexical scope

A function that calls itself. Every recursion needs a base case that stops it.

function factorial(n) {
if (n <= 1) return 1; // base case
return n * factorial(n - 1);
}
factorial(5); // => 120

Recursion shines on tree-shaped data:

function flattenDeep(arr) {
return arr.reduce(
(acc, item) => acc.concat(Array.isArray(item) ? flattenDeep(item) : item),
[],
);
}
flattenDeep([1, [2, [3, [4]]]]); // => [1, 2, 3, 4]
// Walk a nested tree and collect every node name
function walk(node, out = []) {
out.push(node.name);
for (const child of node.children ?? []) walk(child, out);
return out;
}
// Iterative version of the tree walk — no stack limit
function walkIterative(root) {
const out = [];
const stack = [root];
while (stack.length) {
const node = stack.pop();
out.push(node.name);
stack.push(...(node.children ?? []));
}
return out;
}

IIFE — immediately invoked function expression

Section titled “IIFE — immediately invoked function expression”

A function defined and called on the spot, used to create a private scope.

(function () {
const secret = 'not visible outside';
console.log(secret);
})();
(() => {
console.log('arrow IIFE');
})();

The wrapping parentheses matter: they turn a statement position into an expression position, so the parser reads it as a function expression.

Historically this was the only way to avoid polluting the global scope, and it is how the “module pattern” worked before ES modules:

const CounterModule = (function () {
let count = 0; // private
return {
increment: () => ++count,
value: () => count,
};
})();

Today, block scope ({ let x }) and ES modules cover both uses. The IIFE survives in one modern role: getting an await into a non-async context.

(async () => {
const res = await fetch('https://example.com/data.json');
console.log(await res.json());
})();
  • Declarations hoist fully; expressions and arrows hoist only their binding.
  • Arrows have no own this, arguments, prototype, or new — perfect for callbacks, wrong for object methods and constructors.
  • Parameter defaults fire only on undefined and are evaluated per call, left to right. Prefer rest parameters over arguments.
  • A closure is a function plus its birth scope; it keeps those variables alive, which enables private state, memoization, once, and debounce.
  • this has four binding rules, in priority order: new, explicit (call/apply/bind), implicit (obj.fn()), default. Arrows opt out and inherit lexically.
  • Detaching a method loses its this; re-attach with bind or wrap in an arrow.
  • Recursion needs a base case and is limited by stack depth; TCO is specified but not implemented.
  • IIFEs created private scope before ES modules; today their main use is an inline async wrapper.