Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 07 / 18 30 MIN

Functions, closures and modules

Function Forms and What this Refers To

Declarations, expressions, arrows and methods each decide this differently. Here is the rule, the failure it produces, and the two ways to repair it.

The bug that introduces most people to this looks like nothing. You write an object with a method, you hand that method to setTimeout or to array.map, and it works when you call it directly and throws when the callback runs. The code did not change. The way it was called did.

That is the whole of the rule. this is not decided where a function is written, with two exceptions you are about to meet. It is decided when the function is called, by the shape of the call site. timer.start() and const fn = timer.start; fn() run the same body with different this, and no amount of reading the function body will tell you which one you have.

Once you can look at a call site and say what this will be, the errors stop being mysterious. The examples below were run on Node 24.11.1 as ES modules, which matters for one of them, and the exact error text is in each block.

The call site decides this, and there are only a few call shapes

Start with the function form that behaves like a method on an object literal. A method defined with the shorthand syntax is still a function, but when you call it through the object, the object becomes this:

const account = {
  owner: "ana",
  balance: 40,
  describe() {
    return `${this.owner}: ${this.balance}`;
  },
};

console.log(account.describe());   // ana: 40

Now remove the object from the call. Assigning the method to a variable and calling that variable is a plain call, and a plain call gives this the value undefined inside a module. Reading this.owner then throws:

const { describe } = account;

describe();   // TypeError: Cannot read properties of undefined (reading 'owner')

Run that same snippet as a CommonJS file and you get no error at all. Non-strict functions substitute the global object for this, so this.owner is undefined and describe() returns the string undefined: undefined. Every ES module is strict, so the module version throws instead. You can confirm which mode a file is in with one line, because a non-strict plain call is the only case where this is globalThis:

// in a .cjs file
const account = { owner: "ana", describe() { return `${this.owner}`; } };
const { describe } = account;
console.log(describe());                                       // undefined
console.log((function () { return this === globalThis; })());   // true

Under a module that second line prints false. When someone reports that their version "returns undefined instead of throwing", this difference is usually why.

The value of this inside a call comes from the call expression, not from the declaration:

Call sitethis inside
obj.method()obj
fn() in a module or a class bodyundefined
fn() in a non-strict functionthe global object
new Fn()the new object under construction
fn.call(a), fn.apply(a, args)a
boundFn() for fn.bind(a)a, permanently
Arrow function bodywhatever it was where the arrow was written

new makes a fresh object, and forgetting it makes a different error

A constructor call is the one call site where this is created rather than supplied. The runtime makes a new object, links it to the function's prototype, calls the function with that object as this, and returns it unless the function returns something else that is an object:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

const p = new Point(1, 2);
console.log("new:", p.x, p.y);   // new: 1 2

Point(3, 4);   // TypeError: Cannot set properties of undefined (setting 'x')

The error is the same shape as the callback bug and has the same cause: a call that is not a method call and not a constructor call, so this is undefined in a module. In a CommonJS file the second call would quietly write x and y onto the global object instead, which is the failure mode that made "use strict" popular. If you are writing constructor functions rather than classes, the guard is new.target, which is undefined for a plain call.

Classes remove the possibility. Calling a class without new is a TypeError from the language, and class bodies are always strict, so the global-object outcome above cannot happen inside one.

Passing a method as a callback loses the object, and the error arrives late

This is the failure the lesson opened with, written out so you can run it. The method works, and the callback does not:

const timer = {
  label: "build",
  start() {
    console.log("start:", this.label);
  },
};

timer.start();          // start: build
setTimeout(timer.start, 0);

The direct call prints start: build. The timer call throws before it can print anything:

start: build
TypeError: Cannot read properties of undefined (reading 'label')
    at Timeout._onTimeout (file:///tmp/this2.mjs:3:34)
    at listOnTimeout (node:internal/timers:605:17)

Notice where the stack points. It names the timer's internal frame and then the line inside your method. Nothing points at the setTimeout call that caused the problem, because passing a function is not an error. The runtime only finds out when the call happens, which for a timer is one tick later and in a frame you do not control. If that timer were a click handler instead, the page would break on a user action and your console would fill with an error pointing at a line you did not change.

Two details make this harder to spot than it should be. First, the method reference timer.start is a perfectly ordinary function value; the connection to timer existed only for the instant of the call. Second, if the method happens to read something that exists on the global object, there is no error at all and you get a wrong answer instead of a stack trace.

Arrow functions keep the this of the place they were written

An arrow function has no this of its own. When the body reads this, it resolves the name in the enclosing scope at the point the arrow was created. In a method, that is the instance, so the callback works:

class Job {
  #label = "deploy";

  log() {
    console.log("log:", this.#label);
  }

  plain() {
    setTimeout(function () {
      console.log("plain:", this.#label);
    }, 0);
  }

  arrow() {
    setTimeout(() => console.log("arrow:", this.#label), 0);
  }
}

const job = new Job();
job.log();
job.plain();
job.arrow();
log: deploy
uncaught -> TypeError: Cannot read private member #label from an object whose class did not declare it
arrow: deploy

The plain version throws a private-field error rather than the usual Cannot read properties of undefined, because inside a class body the code is strict and the timer calls the function with no receiver, so this is undefined and the private-field check rejects it. The message is worth recognising: it means "the object on the left of the dot is not the instance you think it is", not "the field is missing".

That is the callback problem arrows were introduced to fix. Before arrows, the fix was to capture the receiver in a variable and use it in the callback, and you still see that pattern in older code:

class JobBeforeArrows {
  label = "deploy";

  startOldStyle() {
    const self = this;
    setTimeout(function () {
      console.log("self:", self.label);
    }, 0);
  }
}

The variable name self is a fossil of that era, along with that and _this. You do not need it now, and mixing the two tricks is a common source of confusion: an arrow inside a method that was itself bound with self picks up this, not self, so a half-finished migration can silently change behaviour.

Arrows differ in two other ways that matter. They cannot be used as constructors, and they have no arguments binding of their own. A method defined in an object literal is also not a constructor, for a different reason: the shorthand has no prototype property.

const obj = { m() { return "method"; } };
console.log(Object.prototype.hasOwnProperty.call(obj.m, "prototype"));   // false
console.log(Object.prototype.hasOwnProperty.call(() => {}, "prototype")); // false

new obj.m();   // TypeError: obj.m is not a constructor

call, apply and bind set the receiver explicitly

When you cannot change the call site, change the function. call and apply invoke once with a receiver you supply, and bind returns a new function with the receiver fixed:

const counter = {
  label: "hits",
  hits: 0,
  record(n) {
    this.hits += n;
    return `${this.label}=${this.hits}`;
  },
};

const bound = counter.record.bind(counter);

console.log(bound(2), bound(3));                      // hits=2 hits=5
console.log(counter.record.call(counter, 1));         // hits=6
console.log(counter.record.apply(counter, [4]));      // hits=10
console.log("rebind attempt:", bound.call({ label: "other", hits: 0 }, 9));
console.log("counter.hits:", counter.hits);
hits=2 hits=5
hits=6
hits=10
rebind attempt: hits=19
counter.hits: 19

Two things are happening in that output. call and apply differ only in how the arguments arrive, spread as a list or as an array, which stops mattering once you use rest parameters. And the rebind attempt did nothing: bound.call(...) still recorded against the original counter, because a bound function's receiver cannot be changed by a later call. That is the property to want in a callback, and the property that bites you when you copy a helper around the codebase and forget it arrived bound.

My rule is to bind at the boundary where the reference leaves the object, which is one line next to the setTimeout or the addEventListener, rather than in the class definition. Binding in a constructor or with a class field creates a new function per instance, and it hides from the reader why the method works when passed around.

arguments is the old rest, and it is not an array

Every non-arrow function has an arguments binding: an array-like object containing the values the call received, including values the parameter list did not name. It is not an array, so you cannot call array methods on it without converting it first:

function oldWay() {
  console.log(Object.prototype.toString.call(arguments), arguments.length);
  return Array.from(arguments).join("-");
}

const newWay = (...parts) => parts.join("-");

function mixed(first, ...rest) {
  return [first, rest];
}

console.log(oldWay("a", "b", "c"));
console.log(newWay("a", "b", "c"));
console.log(JSON.stringify(mixed(1, 2, 3)));
[object Arguments] 3
a-b-c
a-b-c
[1,[2,3]]

Use rest parameters. They are real arrays, they appear in the parameter list where a reader will see them, they work in arrow functions, and they are honest about arity: function mixed(first, ...rest) has length 1, while a function that reads arguments instead advertises length 0 and tells the reader nothing. If you need to support a real arguments case, it is usually a wrapper that must forward every argument untouched, and a rest parameter forwards them equally well.

One caveat that costs people an afternoon: arguments in a non-arrow function is not a snapshot of the parameter values, it is linked to them in sloppy mode. Assign to a parameter and the matching arguments entry changes too, unless the function is strict. In a module this cannot happen to you, which is another small reason to keep new code in modules.

Lab 25 minutes

The object below is a billing receipt. format works when called directly. Write down what the file prints before you run it, including which line throws, then produce three different repairs, each one line, and say which of the three you would ship.

const receipt = {
  currency: "EUR",
  total: 120,
  format() {
    return `${this.total} ${this.currency}`;
  },
};

console.log(receipt.format());

setTimeout(() => {
  const fn = receipt.format;
  console.log(fn());
}, 0);

Then answer these two, with the output as your evidence:

If the object also had a top-level variable named total in the module scope, would the failing call print that variable? And if you changed format to an arrow function on the object, would the direct call keep working?

Show the solution and the reasoning

Here is the file with each answer printed, run as an ES module:

console.log(receipt.format());

setTimeout(() => {
  const fn = receipt.format;
  try {
    console.log(fn());
  } catch (error) {
    console.log("detached ->", error.name + ": " + error.message);
  }
}, 0);
120 EUR
detached -> TypeError: Cannot read properties of undefined (reading 'total')

Three one-line repairs, and they are not interchangeable.

Bind at the point where the reference escapes: const fn = receipt.format.bind(receipt);. This is the only repair that leaves format untouched, which matters if other code calls it through the object.

Wrap the call in an arrow: setTimeout(() => console.log(receipt.format()), 0);. The arrow reads receipt from the enclosing scope and the method is invoked through the object, so the call site supplies the receiver. This is the repair I would ship, because it is local to the call and it works whether or not the method is ever detached again.

Move the binding into the object, as a class field or an explicit format: function () {...}.bind(receipt). It works, but it hides the reason the callback no longer throws, and in a class it allocates one function per instance. Use it when the same method is passed around in a dozen places.

On the first question: no. Inside format, this is not the module scope, and this.total does not fall back to a variable named total. There is no scope chain for property access; this.total is a property read on whatever object this holds, which is undefined here, so the read throws before any variable named total could be consulted. The module-scope variable would only matter if the body said total with no dot.

On the second question: no, and this one catches people out. An arrow function as an object property does not become a method with a receiver. It captures this from the enclosing scope, which at the top level of a module is undefined, so the direct call receipt.format() would also throw. Arrows are the right tool inside a method body and the wrong tool as a method definition. That asymmetry is the practical summary of the whole lesson: the call site supplies this, except for arrows, which refuse to accept it.

Progress

Mark this lesson done and it stays marked in this browser.