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.
Three ways to define a function
Section titled “Three ways to define a function”Function declaration
Section titled “Function declaration”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' — worksfunction greet() { return 'hi'; }Function expression
Section titled “Function expression”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 tracesArrow function
Section titled “Arrow function”Shorter syntax, and crucially no own this, arguments, super, or new.target.
const add = (a, b) => a + b; // implicit returnconst square = (n) => n * n;const noop = () => {}; // empty bodyconst make = () => ({ ok: true }); // object literal needs parenthesesconst 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 |
Parameters
Section titled “Parameters”Defaults
Section titled “Defaults”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 parameters
Section titled “Rest parameters”...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); // => 10The arguments object
Section titled “The arguments object”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');length and name
Section titled “length and name”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).
First-class and higher-order functions
Section titled “First-class and higher-order functions”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); // => 5A higher-order function takes a function, returns a function, or both. map, filter, setTimeout, and addEventListener are all higher-order.
// Takes a functionconst twice = (fn, x) => fn(fn(x));twice((n) => n + 3, 1); // => 7
// Returns a functionconst multiplyBy = (factor) => (n) => n * factor;const double = multiplyBy(2);double(21); // => 42A 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 scope
Section titled “Lexical scope”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();Closures
Section titled “Closures”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(); // => 1c.increment(); // => 2c.get(); // => 2c.count; // => undefined — genuinely inaccessibleEach call to makeCounter creates a new, independent scope:
const a = makeCounter();const b = makeCounter();a.increment(); a.increment();b.increment();a.get(); // => 2b.get(); // => 1Worked example: memoization
Section titled “Worked example: memoization”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); // => 16slowSquare(4); // => 16calls; // => 1Worked example: once
Section titled “Worked example: once”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 logWorked example: debounce
Section titled “Worked example: debounce”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 callThe classic closure bug
Section titled “The classic closure bug”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.
this — the four binding rules
Section titled “this — the four binding rules”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).
1. new binding
Section titled “1. new binding”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'2. Explicit binding — call, apply, bind
Section titled “2. Explicit binding — call, apply, bind”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'3. Implicit binding — method call
Section titled “3. Implicit binding — method call”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 counterThe 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); // brokensetTimeout(() => counter.increment(), 100); // fixed: real method callsetTimeout(counter.increment.bind(counter), 100); // also fixed4. Default binding
Section titled “4. Default binding”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(); // => undefinedArrow functions ignore all four
Section titled “Arrow functions ignore all four”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 |
Recursion
Section titled “Recursion”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); // => 120Recursion 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 namefunction 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 limitfunction 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());})();Key points
Section titled “Key points”- Declarations hoist fully; expressions and arrows hoist only their binding.
- Arrows have no own
this,arguments,prototype, ornew— perfect for callbacks, wrong for object methods and constructors. - Parameter defaults fire only on
undefinedand are evaluated per call, left to right. Prefer rest parameters overarguments. - A closure is a function plus its birth scope; it keeps those variables alive, which enables private state, memoization,
once, anddebounce. thishas 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 withbindor 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
asyncwrapper.