Functions and Scope

Article · 10:00

Three ways to write a function, and the differences between them are not stylistic - they change when the function exists and what this means inside it. Declarations, expressions and arrows, with the hoisting rule for each.

This lesson has 8 runnable examples. Press Try yourself! under any of them to open it in the editor.

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

JS Example 1 16 lines
// 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.

JS Example 2 14 lines
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.
  • var is hoisted and initialised to undefined. The read does not throw; it silently gives you nothing, which is worse.
  • let and const are hoisted but left uninitialised. Reading one before its declaration throws a ReferenceError. That region is called the temporal dead zone, and it exists precisely so the var behaviour 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.

JS Example 3 8 lines
// 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.

JS Example 4 12 lines
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.

JS Example 5 13 lines
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:

  1. new Thing() - this is the newly created object.
  2. fn.call(obj), fn.apply(obj), fn.bind(obj) - this is obj, explicitly.
  3. obj.fn() - this is obj, implicitly, from the dot.
  4. A bare fn() - this is undefined in modules and strict mode, and the global object in sloppy mode. Prefer modules; the undefined is 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:

JS Example 6 9 lines
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

JS Example 7 61 lines
// 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

JS Example 8 16 lines
// 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.