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 site | this inside |
|---|---|
obj.method() | obj |
fn() in a module or a class body | undefined |
fn() in a non-strict function | the 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 body | whatever 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.
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.