Functions, scope and closures
Functions are the main building block of JavaScript, and most of the language's surprising behaviour comes from three rules: where a name is visible, when it exists, and what this means. This lesson shows the mechanism behind each, then uses closures to build discount rules and a cache.
By the end you can
- Pick between a function declaration, a function expression and an arrow function
- Trace how JavaScript finds a name through nested scopes, and explain the temporal dead zone
- Explain what a closure keeps alive, and use one to build a counter, a formatter and a memoize helper
- Predict what
thisis in a normal function and in an arrow function, and fix the classic lost-thisbug
The everyday picture
At the start of a shift, the shift manager opens each counter and puts a float in the cash drawer: say $100 in small notes and coins for change. Then the manager goes back to the office or goes home. The drawer does not forget its float. Counter 3's drawer keeps its own $100, and every sale adds to that drawer only. Counter 4 has a separate drawer with a separate float, and the two never mix.
A closure is that drawer. A function that creates another function is the shift manager. When it finishes, the inner function still holds the variables it was given, and each call makes a fresh drawer. To understand why that works, you first need to see where names live.
Three ways to write a function
A function declaration starts with the keyword function and a name. A function expression creates a function as a value and stores it in a variable. An arrow function is a shorter expression. All three can do the same job:
function lineTotal(qty, unitPrice) { return qty * unitPrice; } // declaration
const lineTotal2 = function (qty, unitPrice) { return qty * unitPrice; }; // expression
const lineTotal3 = (qty, unitPrice) => qty * unitPrice; // arrow
// Default values and a rest parameter
function receiptLine(articleCode, qty = 1, ...notes) {
return `${articleCode} x${qty}${notes.length ? ' (' + notes.join(', ') + ')' : ''}`;
}
console.log(lineTotal(2, 1250), lineTotal2(2, 1250), lineTotal3(2, 1250));
console.log(receiptLine('100234'));
console.log(receiptLine('100234', 3, 'festival offer', 'gift wrap'));
console.log(receiptLine('100234', undefined)); // undefined triggers the default
console.log(receiptLine('100234', null)); // null does not
console.log(lineTotal(2)); // a missing argument is undefined
const toObj = (siteId) => ({ siteId }); // returning an object: wrap it in ( )
const broken = (siteId) => { siteId }; // braces mean a body; returns nothing
console.log(toObj('S014'), broken('S014'));2500 2500 2500
100234 x1
100234 x3 (festival offer, gift wrap)
100234 x1
100234 xnull
NaN
{ siteId: 'S014' } undefinedFour details from that output matter in real code. A default value is used only when the argument is undefined, so an API that sends null skips it. A missing argument does not cause an error; it is undefined, and 2 * undefined is NaN. The rest parameter ...notes collects the extra arguments into a real array. And an arrow function returns its expression automatically only when there are no braces, so an object literal must be wrapped in parentheses.
Which to use? Teams usually write top-level, named helpers as declarations or const arrows (both are fine; pick one in the style guide), and use arrows for short callbacks such as sales.map((s) => s.qty). Arrow functions also differ in one big way: they have no this of their own. That is covered below, and it is the reason arrows are the right choice for callbacks inside methods.
Quick check
A checkout calls receiptLine('100234', null) because the API sent qty: null. What does the receipt line say?
A default parameter is used only when the argument is undefined. null is a real value that was passed, so it goes straight into the template literal. If your API can send null, convert it with qty ?? 1 inside the function.
Scope: where a name is visible
Scope is the region of code where a name can be used. JavaScript has three kinds you meet every day. Block scope: let and const inside { } of an if, a loop or a bare block exist only there. Function scope: parameters and variables declared in a function exist only during that call. Module scope: top-level names in a file are visible to the whole file, and to other files only if you export them (Prototypes, classes and modules).
Scopes nest, and lookup always goes outward, never inward. When code uses a name, JavaScript looks in the current block first, then the enclosing function, then the module, then the global scope. The first match wins. If nothing matches, you get a ReferenceError. Outer code can never see names declared inside an inner block.
const storeName = 'Riverside'; // module scope: the whole file sees it
function closeShift(sales) {
let total = 0; // function scope: only inside closeShift
for (const sale of sales) {
const line = sale.qty * sale.unitPrice; // block scope: only inside the loop body
total += line; // "total" is found one scope outward
}
try { console.log(line); } // outside the block: not visible
catch (e) { console.log(e.name + ': ' + e.message); }
return `${storeName}: ${total} cents`; // "storeName" is found two scopes out
}
console.log(closeShift([{ qty: 2, unitPrice: 1250 }, { qty: 1, unitPrice: 450 }]));
if (true) { var leaky = 'var ignores blocks'; let tight = 'let respects blocks'; }
console.log(leaky, typeof tight);ReferenceError: line is not defined
Riverside: 2950 cents
var ignores blocks undefinedThe last two lines show why var is banned: it belongs to the whole function (or the whole script), so it leaks out of the if block. let stays where it was declared.
Quick check
A file declares const total = 99 at the top, and closeShift declares its own let total = 0. Which total does the loop inside closeShift add to?
Lookup goes outward from where the name is used and stops at the first match. The function's total shadows the module's one inside the function. Declaring the same name in different scopes is legal; only the same name twice in one scope is an error.
line.Hoisting and the temporal dead zone
Before any line of a scope runs, JavaScript scans the scope and creates every name declared in it. This is called hoisting. What differs is the state each name starts in:
- A function declaration is created complete, so you can call it above the line where it is written.
- A
varis created with the valueundefined, so reading it early givesundefinedwithout any error. That silence hides bugs. - A
let,constorclassis created but left uninitialised. From the top of the scope until its declaration line runs, it is in the temporal dead zone (TDZ), and touching it throws aReferenceError.
console.log(addTax(1000)); // works: a function declaration is ready early
console.log(oldStyle); // var exists early, but is undefined
try { console.log(total); } // let exists, but is in the dead zone
catch (e) { console.log(e.name + ': ' + e.message); }
try { console.log(formatUsd(5000)); } // a const arrow function: same dead zone
catch (e) { console.log(e.name + ': ' + e.message); }
function addTax(cents) { return cents + Math.round(cents * 0.15); }
var oldStyle = 'set now';
let total = 0;
const formatUsd = (cents) => `USD ${(cents / 100).toFixed(2)}`;1150
undefined
ReferenceError: Cannot access 'total' before initialization
ReferenceError: Cannot access 'formatUsd' before initializationThe message "Cannot access … before initialization" is the TDZ. The message "… is not defined" means the name does not exist in any enclosing scope at all. Knowing which one you are looking at tells you whether the problem is order or spelling.
Closures: a function keeps its scope alive
Each time a function runs, JavaScript creates a fresh set of its local variables, called its environment. Normally that environment is thrown away when the call returns. But if the call creates an inner function that uses those variables, and the inner function survives (it is returned, stored, or passed as a callback), then the environment survives too. The inner function plus the variables it keeps is a closure.
function makeTokenCounter(prefix) {
let next = 1; // survives after makeTokenCounter returns
return function issue() {
const token = `${prefix}-${String(next).padStart(3, '0')}`;
next += 1;
return token;
};
}
const counter1 = makeTokenCounter('C1'); // two calls, two separate "next" variables
const counter2 = makeTokenCounter('C2');
console.log(counter1(), counter1(), counter2(), counter1());
function makePriceFormatter(currency, decimals) {
return (cents) => `${currency} ${(cents / 100).toFixed(decimals)}`;
}
const usd = makePriceFormatter('USD', 2);
const usdWhole = makePriceFormatter('USD', 0);
console.log(usd(1250), usdWhole(1250));C1-001 C1-002 C2-001 C1-003
USD 12.50 USD 13Three facts explain that output. The closure keeps the variable itself, not a copy of its value, which is why next keeps going up. Each call to the outer function makes a new environment, so counter2 starts again at 1. And the variable is private: no code outside can read or reset next, because no outside scope contains it. That is how JavaScript did private state for about twenty years before classes got #private fields (Prototypes, classes and modules).
Quick check
After the code above, you create const counter3 = makeTokenCounter('C3') and call it once. What does it return?
Every call to makeTokenCounter creates a fresh environment with its own next starting at 1. Counters only share a variable if it lives outside the factory, which is exactly the bug in the second Practice task.
makeTokenCounter has finished, but each returned issue function still holds its own prefix and next. After three calls, counter 1's next is 4; counter 2's separate one is 2.Think first: this loop schedules three receipts to print later. Why does it print "4" three times?
for (var i = 1; i <= 3; i++) {
setTimeout(() => console.log('var receipt', i), 0);
}
for (let j = 1; j <= 3; j++) {
setTimeout(() => console.log('let receipt', j), 0);
}var receipt 4
var receipt 4
var receipt 4
let receipt 1
let receipt 2
let receipt 3All three arrow functions close over the same var i, because var has one variable for the whole function. The timers run after the loop has finished (The event loop shows why), and by then i is 4. With let, each loop iteration gets its own fresh j, so each closure keeps a different variable.
this: decided by the call, unless it is an arrow
Inside a normal function or method, this is not fixed when you write the function. It is decided each time the function is called, by how it is called. Call it as store.label() and this is store. Copy the same function into a variable and call label(), and this is undefined in strict mode (which ES modules and classes always use). In an old non-strict script it is worse: this becomes the global object, and your code writes to globals without any error.
An arrow function has no this of its own. It uses the this of the scope it was written in, exactly like any other outer variable. That makes arrows the right tool for callbacks inside methods, and the wrong tool for the methods themselves.
// this-lost.mjs
const store = {
siteId: 'S014',
label() { return `Store ${this.siteId}`; },
};
console.log(store.label()); // called on store: this = store
const label = store.label; // the same function, cut off
try { label(); } catch (e) { console.log(e.name + ': ' + e.message); }
const bound = store.label.bind(store); // bind fixes "this" for good
console.log(bound());Store S014
TypeError: Cannot read properties of undefined (reading 'siteId')
Store S014Passing a method as a callback, as in rows.map(this.toDto) or button.onPress = this.save, is the same as cutting it off: the receiver does not call it on your object. Wrap it in an arrow (rows.map((r) => this.toDto(r))) or bind it.
Higher-order and pure functions
In JavaScript a function is a value: you can pass it in, return it, and keep it in an array. A higher-order function takes a function, returns one, or both. You already use them: sales.filter((s) => !s.voided) passes a function to filter. The factories above return one.
A pure function gives the same output for the same input and changes nothing outside itself. (total, sale) => total + sale.qty * sale.unitPrice is pure. A function that adds to a module-level todayTotal is not: calling it twice with the same sale gives two different answers. Pure functions are easy to test (no setup, no mocks), safe to cache, and safe to run in any order. Keep pricing, discount and tax rules pure, and push the impure parts (reading the database, writing logs, sending the receipt by text message) to the edges of the code.
Build it
Build a discount-rule factory from closures, a memoize helper, and fix a lost-this bug in a service class. Each file runs with node. The Practice section repeats the factory and the counter as exercises you can run in your browser.
-
A discount-rule factory
Each factory returns a small rule function, a closure over its settings.
bestOfis higher-order: it takes rules and returns one rule that picks the biggest discount. All rules are pure, so they are easy to test.JavaScript// discount-rules.js function percentOff(percent, { minCents = 0 } = {}) { // percent and minCents stay alive inside the returned function return (cart) => cart.totalCents >= minCents ? Math.round((cart.totalCents * percent) / 100) : 0; } function flatOff(amountCents, ...articleCodes) { // rest: any number of codes return (cart) => cart.lines.some((l) => articleCodes.includes(l.articleCode)) ? amountCents : 0; } function bestOf(...rules) { // takes functions, returns one return (cart) => Math.max(0, ...rules.map((rule) => rule(cart))); } const festivalOffer = bestOf( percentOff(10, { minCents: 5000 }), // 10% off carts of $50 or more flatOff(500, '200871', '200872'), // $5 off if the cart has a rice cooker ); const carts = { big: { totalCents: 7500, lines: [{ articleCode: '100234' }] }, cooker: { totalCents: 3000, lines: [{ articleCode: '200871' }] }, small: { totalCents: 2400, lines: [{ articleCode: '100234' }] }, }; for (const [name, cart] of Object.entries(carts)) { console.log(`${name.padEnd(6)} discount: ${festivalOffer(cart)} cents`); }Terminalnode discount-rules.jsOutputbig discount: 750 cents cooker discount: 500 cents small discount: 0 centsMarketing's next offer becomes one more line, such as
percentOff(5), with no change to checkout code. -
A memoize helper
memoizewraps a slow pure function. ThecacheMap lives in the closure, so only the wrapper can see it.JavaScript// memoize.js function memoize(fn) { const cache = new Map(); // private: only the inner function sees it return function (key) { if (cache.has(key)) return cache.get(key); const result = fn(key); cache.set(key, result); return result; }; } let realCalls = 0; function taxRateFor(articleCode) { // imagine a slow lookup in a big rate table realCalls += 1; return articleCode.startsWith('1') ? 0 : 15; // made-up rule for the example } const taxRate = memoize(taxRateFor); const basket = ['100234', '200871', '100234', '200871', '100234']; console.log(basket.map(taxRate)); console.log('real lookups:', realCalls);Output[ 0, 15, 0, 15, 0 ] real lookups: 2Two limits to say out loud in review. Only memoize pure functions: if the tax table can change, the cache serves stale rates. And this cache never shrinks; with thousands of keys in a long-running server it becomes a memory leak (Performance and memory in JavaScript builds a bounded version).
-
Reproduce a lost-
thisbugA service class maps database rows to response objects. Passing
this.toDtotomaphands over the bare function, so inside itthisisundefined. Class bodies are always strict, so it fails loudly.JavaScript// stock-service.mjs class PriceService { format(cents) { return `USD ${(cents / 100).toFixed(2)}`; } } class StockService { constructor(prices) { this.prices = prices; } toDto(row) { return { articleCode: row.articleCode, price: this.prices.format(row.unitPrice) }; } listBroken(rows) { return rows.map(this.toDto); } // passes the bare method list(rows) { return rows.map((row) => this.toDto(row)); } // arrow keeps "this" } const svc = new StockService(new PriceService()); const rows = [{ articleCode: '100234', unitPrice: 1250 }]; try { svc.listBroken(rows); } catch (e) { console.log(e.name + ': ' + e.message); } console.log(svc.list(rows));Terminalnode stock-service.mjsOutputTypeError: Cannot read properties of undefined (reading 'prices') [ { articleCode: '100234', price: 'USD 12.50' } ] -
See the same bug with a plain callback
The forEach version is the most common form of this bug in app code. The normal
functioncallback gets its ownthis(undefined), while the arrow uses the method'sthis.JavaScript// scan-counter.mjs const counter = { siteId: 'S014', scanned: 0, scanAllBroken(codes) { codes.forEach(function (code) { this.scanned += 1; // a normal function: "this" is undefined here }); return this.scanned; }, scanAll(codes) { codes.forEach((code) => { this.scanned += 1; // an arrow: "this" is scanAll's "this", the counter }); return this.scanned; }, }; try { counter.scanAllBroken(['100234', '200871']); } catch (e) { console.log(e.name + ': ' + e.message); } console.log(counter.scanAll(['100234', '200871']));OutputTypeError: Cannot read properties of undefined (reading 'scanned') 2Rename it to
scan-counter.cjsand run it again: a CommonJS file is not strict by default, so there is no error at all. The only output is2. The broken method returned0and quietly created a globalscannedset toNaN. A loud error is the better outcome.
Bring it to your team
Closures and this problems rarely show up in a quick demo; they show up under load or months later. Ask:
- "Is any per-request or per-user data stored in a module-level variable in this service?"
- "Are the pricing, discount and tax rules pure functions with unit tests, separate from the code that reads the database?"
- "This cache lives in a closure. What is its maximum size, and what happens when the source data changes?"
- "Do we pass any class methods as callbacks without an arrow or
bind?" - "Is the code in ES modules or strict mode, so a lost
thisfails loudly?"
Red flags
varanywhere in new code, or loops creating callbacks withvar.let current…at the top of a server file, set inside a request handler.- An unbounded memoize or "simple cache" Map in a long-running API.
const self = this;or.bind(this)scattered everywhere, a sign the code fightsthisinstead of using arrows for callbacks.
Practice
Build two discount-rule factories
Write percentOff(percent, { minCents }), which returns a rule: a function that takes a cart such as { totalCents: 7500 } and returns the discount in cents, rounded with Math.round, or 0 when the cart is below minCents (default 0). Then write bestOf(...rules), which returns one rule that gives the biggest discount of all its rules, and never less than 0.
function percentOff(percent, { minCents = 0 } = {}) {
// return a rule: a function that takes a cart and returns cents off
}
function bestOf(...rules) {
// return one rule that gives the biggest discount of all the rules
}
test('percentOff returns a function (a factory returns a rule, not a number)', () => {
expect(typeof percentOff(10)).toBe('function');
});
test('10% off a $75.00 cart is 750 cents', () => {
expect(percentOff(10)({ totalCents: 7500 })).toBe(750);
});
test('gives 0 below the minimum, and the full discount exactly at it', () => {
const rule = percentOff(10, { minCents: 5000 });
expect(rule({ totalCents: 4999 })).toBe(0);
expect(rule({ totalCents: 5000 })).toBe(500);
});
test('each rule keeps its own settings (two rules from one factory do not share percent)', () => {
const ten = percentOff(10);
const five = percentOff(5);
expect(ten({ totalCents: 2000 })).toBe(200);
expect(five({ totalCents: 2000 })).toBe(100);
});
test('bestOf picks the biggest discount, and gives 0 when no rule applies', () => {
const offer = bestOf(percentOff(10, { minCents: 5000 }), percentOff(5));
expect(offer({ totalCents: 6000 })).toBe(600);
expect(offer({ totalCents: 2000 })).toBe(100);
expect(bestOf(percentOff(10, { minCents: 5000 }))({ totalCents: 100 })).toBe(0);
});
Show a solution
function percentOff(percent, { minCents = 0 } = {}) {
return (cart) => // percent and minCents live on here
cart.totalCents >= minCents ? Math.round((cart.totalCents * percent) / 100) : 0;
}
function bestOf(...rules) {
return (cart) => Math.max(0, ...rules.map((rule) => rule(cart)));
}Fix the counters that share a number
The Store app issues queue tokens per counter. Each call to makeTokenCounter('C1') should return a function that gives C1-001, then C1-002, and so on, independently of every other counter. Right now two counters interfere with each other. Move one line to fix it.
let next = 1;
function makeTokenCounter(prefix) {
return function issue() {
const token = `${prefix}-${String(next).padStart(3, '0')}`;
next += 1;
return token;
};
}
test('a new counter starts at 001 even after another counter was used (next must live inside makeTokenCounter)', () => {
const a = makeTokenCounter('A');
a(); a();
const b = makeTokenCounter('B');
expect(b()).toBe('B-001');
});
test('one counter keeps counting up: C1-001, C1-002, C1-003', () => {
const c = makeTokenCounter('C1');
expect([c(), c(), c()]).toEqual(['C1-001', 'C1-002', 'C1-003']);
});
test('calls to one counter do not move the other counter', () => {
const c1 = makeTokenCounter('C1');
const c2 = makeTokenCounter('C2');
c1(); c1(); c1();
expect(c2()).toBe('C2-001');
expect(c1()).toBe('C1-004');
});
Show a solution
function makeTokenCounter(prefix) {
let next = 1; // one fresh variable per call: each counter's own drawer
return function issue() {
const token = `${prefix}-${String(next).padStart(3, '0')}`;
next += 1;
return token;
};
}Fix the lost this
StockService.list(rows) should turn each row into { articleCode, price }, where price comes from the service's own price formatter. It throws instead. Fix list without changing toDto.
class StockService {
constructor(prices) { this.prices = prices; }
toDto(row) {
return { articleCode: row.articleCode, price: this.prices.format(row.unitPrice) };
}
list(rows) {
return rows.map(this.toDto);
}
}
const prices = { format: (cents) => `USD ${(cents / 100).toFixed(2)}` };
test("list maps rows with the service's own formatter (passing this.toDto to map loses this)", () => {
const svc = new StockService(prices);
expect(svc.list([{ articleCode: '100234', unitPrice: 1250 }]))
.toEqual([{ articleCode: '100234', price: 'USD 12.50' }]);
});
test('list works for several rows and an empty list', () => {
const svc = new StockService(prices);
expect(svc.list([])).toEqual([]);
expect(svc.list([{ articleCode: 'a', unitPrice: 5 }, { articleCode: 'b', unitPrice: 199 }]))
.toEqual([{ articleCode: 'a', price: 'USD 0.05' }, { articleCode: 'b', price: 'USD 1.99' }]);
});
Show a solution
class StockService {
constructor(prices) { this.prices = prices; }
toDto(row) {
return { articleCode: row.articleCode, price: this.prices.format(row.unitPrice) };
}
list(rows) {
return rows.map((row) => this.toDto(row)); // the arrow keeps list's this
}
}Check yourself
You call counter1() three times and counter2() once, both made by makeTokenCounter. Why does counter2() return C2-001 rather than C2-004?
Every call to the outer function creates a fresh set of local variables, and the returned function keeps that set. The two counters hold two different next variables. Closures keep the variable itself, not a copy, which is why counter1 keeps counting up.
This line is at the top of a file, above const formatUsd = (c) => …: console.log(formatUsd(5000)). What happens?
Only function declarations are ready early. formatUsd is a const, so the name exists from the top of the scope but is in the temporal dead zone until its line runs. "Not defined" would mean the name does not exist anywhere; undefined is what a var would give.
Inside a class method, rows.map(this.toDto) throws "Cannot read properties of undefined". What is the best fix?
this is decided by how a function is called, and map calls the bare function. The arrow callback calls this.toDto(row) on the object, and arrows take this from the method. An arrow as an object-literal method gets the module's this instead. Non-strict mode only hides the bug by pointing this at the global object.
Flashcards
Key takeaways
- Declarations, expressions and arrows all make functions; arrows have no
thisof their own and return their expression when there are no braces. - Name lookup goes outward through block, function and module scope, never inward.
letandconstare block-scoped;varleaks. - Everything is hoisted, but
let,constandclasssit in the temporal dead zone until their line runs. - A closure keeps the outer variables themselves alive. Each outer call makes a new private set. Unbounded closure caches leak memory.
thisis set by the call. Use method syntax for methods and arrows for the callbacks inside them. Keep business rules pure.