Functions and Scope
Two questions account for most confusion about JavaScript functions: when does a name exist, and what is this. Both have precise answers. Neither answer is "it depends".
Four ways to make a function, and why it matters
// 1. Declaration - hoisted completely, name and body.
function area(w, h) { return w * h; }
// 2. Expression assigned to a const - the binding is hoisted, the
// value is not, so it exists but cannot be used before this line.
const volume = function (w, h, d) { return w * h * d; };
// 3. Arrow - same hoisting as 2, but no own this, arguments or
// prototype, and it cannot be used with new.
const perimeter = (w, h) => 2 * (w + h);
// 4. Method shorthand in an object or class.
const shape = {
w: 3, h: 4,
area() { return this.w * this.h; },
};
Open this example in the editor and change it. Nothing you type is saved.
The differences that matter day to day are hoisting and this. Everything else is style.
Hoisting, precisely
Hoisting is not "declarations move to the top". It is that the engine creates every binding in a scope before running any of its code, and then decides what each binding holds until its declaration line is reached.
console.log(declared()); // 'works' - function declarations are complete
console.log(hoistedVar); // undefined - the binding exists, holds undefined
// The third case throws, so it is caught here rather than ending the
// example. Delete the try to see it stop the file.
try {
console.log(letBinding);
} catch (error) {
console.log(error.constructor.name); // 'ReferenceError'
}
function declared() { return 'works'; }
var hoistedVar = 1;
let letBinding = 2;
Open this example in the editor and change it. Nothing you type is saved.
- A function declaration is hoisted whole. You can call it above its own definition, which is why a file can read top-down with helpers at the bottom.
varis hoisted and initialised toundefined. The read does not throw; it silently gives you nothing, which is worse.letandconstare hoisted but left uninitialised. Reading one before its declaration throws aReferenceError. That region is called the temporal dead zone, and it exists precisely so thevarbehaviour above cannot happen.
Use const by default, let when you must reassign, and var never. That is not fashion: var is function-scoped rather than block-scoped, which produces a classic bug.
// The loop everyone has met.
const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
console.log(withVar.map((f) => f())); // [3, 3, 3] - one shared i
const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
console.log(withLet.map((f) => f())); // [0, 1, 2] - a fresh j each pass
Open this example in the editor and change it. Nothing you type is saved.
let in a for header creates a new binding per iteration. That is a special rule written into the language because this bug was so common.
Scope is lexical, which means it is readable
A function can see the variables of the scope it was written in, not the scope it is called from. This is decided when you write the code, so you can answer "which x is this" by reading outwards from the function - you never need to know who calls it.
const outer = 'module';
function middle() {
const outer = 'middle';
return inner(); // calls inner, but inner cannot see this one
}
function inner() {
return outer; // 'module' - where inner was written
}
console.log(middle()); // 'module'
Open this example in the editor and change it. Nothing you type is saved.
If scope were dynamic, inner would see middle's outer and the answer would depend on the call site. It is not, and that is a feature: a function's meaning is local.
this, in one rule
this is not about where a function is defined. For an ordinary function, this is whatever is to the left of the dot at the call site. That single sentence covers nearly every case.
const counter = {
count: 0,
increment() { this.count++; return this.count; },
};
counter.increment(); // this === counter, because of 'counter.'
const loose = counter.increment;
// loose(); // this is undefined in a module - TypeError
loose.call(counter); // explicit: this === counter
const bound = counter.increment.bind(counter);
bound(); // this === counter, permanently
Open this example in the editor and change it. Nothing you type is saved.
Four binding rules, in the order the engine checks them:
new Thing()-thisis the newly created object.fn.call(obj),fn.apply(obj),fn.bind(obj)-thisisobj, explicitly.obj.fn()-thisisobj, implicitly, from the dot.- A bare
fn()-thisisundefinedin modules and strict mode, and the global object in sloppy mode. Prefer modules; theundefinedis a useful error rather than a silent global.
An arrow function obeys none of these. It has no this of its own, so this inside it means whatever this meant in the enclosing scope, permanently. That is exactly what you want in a callback and exactly what you do not want in an object literal:
const timer = {
seconds: 0,
// Right: the arrow keeps the method's this.
startGood() { setInterval(() => { this.seconds++; }, 1000); },
// Wrong: the ordinary function is called bare, so this is undefined.
startBad() { setInterval(function () { this.seconds++; }, 1000); },
// Wrong in a different way: an arrow as a method has no object this.
reset: () => { this.seconds = 0; },
};
Open this example in the editor and change it. Nothing you type is saved.
A worked example
// A small event emitter, exercising scope, this, and closures together.
function createEmitter() {
// Private to every emitter, by closure rather than convention.
const handlers = new Map();
return {
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(handler);
// Return an unsubscribe closure over the exact handler.
return () => handlers.get(event).delete(handler);
},
emit(event, payload) {
const set = handlers.get(event);
if (!set) return 0;
// A copy, so a handler that unsubscribes during emit is safe.
for (const handler of [...set]) handler(payload);
return set.size;
},
get count() {
let total = 0;
for (const set of handlers.values()) total += set.size;
return total;
},
};
}
const bus = createEmitter();
const log = [];
const off = bus.on('save', (p) => log.push('first: ' + p));
bus.on('save', (p) => log.push('second: ' + p));
bus.emit('save', 'doc-1');
console.log(log); // ['first: doc-1', 'second: doc-1']
console.log(bus.count); // 2
off(); // the closure remembers which handler
bus.emit('save', 'doc-2');
console.log(log); // ...plus 'second: doc-2' only
console.log(bus.count); // 1
// Now the this trap, demonstrated rather than described.
const listener = {
seen: [],
handle(p) { this.seen.push(p); },
};
// Passed as a value, the dot is left behind and this is undefined.
try {
listener.handle.call(undefined, 'doc-3');
} catch (error) {
console.log(error.constructor.name); // 'TypeError'
}
// The fix: keep the dot by wrapping the call, or bind it once.
bus.on('save', (p) => listener.handle(p));
bus.emit('save', 'doc-4');
console.log(listener.seen); // ['doc-4']
Open this example in the editor and change it. Nothing you type is saved.
Three things are worth tracing. handlers is never exported, yet every method can reach it - that is closure, and it is a stronger privacy than a naming convention. The unsubscribe function returned by on closes over the specific handler, so it needs no arguments. And listener.handle loses its this the moment it is passed as a value, because the dot was left behind.
Default parameters, rest and arity
// Defaults are evaluated at call time, left to right, and can refer
// to earlier parameters.
function slice(list, start = 0, end = list.length) {
return list.slice(start, end);
}
// Rest collects the remainder into a real array - unlike arguments,
// which is array-like and absent from arrow functions.
function tally(label, ...amounts) {
return label + ': ' + amounts.reduce((a, b) => a + b, 0);
}
console.log(tally('total', 1, 2, 3)); // 'total: 6'
// Only parameters before the first default count towards length.
console.log(slice.length); // 1
console.log(tally.length); // 1
Open this example in the editor and change it. Nothing you type is saved.
A default is re-evaluated on every call, which matters when it is an object: function f(opts = {}) gives a fresh object each time, not one shared between calls. That is the opposite of Python, and it is the safer choice.
When it goes wrong
| Symptom | Cause |
|---|---|
Cannot read properties of undefined (reading ...) inside a method |
The method was passed as a value and lost its this |
| All closures in a loop see the last value | var in the loop header; use let |
ReferenceError: Cannot access before initialization |
A let or const read in its temporal dead zone |
A variable is undefined rather than throwing |
var hoisting hid the mistake |
this is undefined in a callback |
An ordinary function where an arrow was wanted |
this is the global object, not your object |
Sloppy mode; use modules or 'use strict' |
| An arrow method cannot see the object | Arrows have no own this; use method shorthand |
new on an arrow throws |
Arrows have no prototype and are not constructors |
A check you can run
Take any function in your own code that reads this and ask one question: can it be called without a dot? If a .map(), an event listener or a setTimeout ever receives it as a bare value, it can - and it will fail the first time someone does.
Then run the withVar and withLet loops above and change nothing but the keyword. The result changes from [3, 3, 3] to [0, 1, 2]. That difference is the whole argument for block scoping, and it fits in four lines.