Ch. 1 · JavaScript

The this Keyword: Four Binding Rules and One Exception

How JavaScript decides what this refers to: default, implicit, explicit and new binding, the order they win in, why arrow functions ignore them, and the bugs that follow.

~6 min readintermediate

Most names in JavaScript are resolved lexically, by reading the code around them. The keyword this is the big exception: for ordinary functions it is decided at call time, by how the function is invoked. That is why the same method can work on one line and throw on the next. Four rules cover almost every case, one ordering settles conflicts between them, and arrow functions opt out of the system entirely. Learn those and every puzzle about it becomes mechanical.

Unless a snippet says otherwise, examples run as ES modules, which are always strict mode (class bodies are strict too). Where sloppy mode or the browser behaves differently, it is called out.

The four binding rules

1. Default binding

A plain call, fn(), with nothing in front of it. In sloppy mode this falls back to the global object (window in a browser script, globalThis in general). In strict mode it is undefined.

// Run as a sloppy-mode script (classic browser script or CommonJS file)
function sloppy() {
  return this;
}

function strict() {
  'use strict';
  return this;
}

console.log(sloppy() === globalThis); // → true (only in sloppy-mode code)
console.log(strict()); // → undefined
JavaScript

Top-level this (outside any function) is its own case: window in a classic browser script, undefined in an ES module, and module.exports (initially {}) in a Node CommonJS file.

2. Implicit binding

Call a function as a property of an object, obj.fn(), and this is that object. Only the object immediately before the call counts.

const user = {
  name: 'Ada',
  greet() {
    return `Hi, ${this.name}`;
  },
  team: {
    name: 'Core',
    greet() {
      return `Hi from ${this.name}`;
    },
  },
};

console.log(user.greet()); // → Hi, Ada
console.log(user.team.greet()); // → Hi from Core (only the last object counts)

const greet = user.greet; // just a function now, no object attached
try {
  greet();
} catch (e) {
  console.log(e.message); // → Cannot read properties of undefined (reading 'name')
}
JavaScript

The method is not “owned” by user. Once you copy the function into a variable and call it plainly, the default rule applies. In a sloppy browser script the last call would not even throw: it returns "Hi, ", because this is window and window.name is an empty string. That silence is how these bugs survive.

3. Explicit binding: call, apply, bind

call and apply invoke a function with the this you pass; they differ only in how arguments are supplied. bind returns a new function with this (and optionally leading arguments) fixed permanently.

function introduce(greeting, punctuation) {
  return `${greeting}, I'm ${this.name}${punctuation}`;
}

const ada = { name: 'Ada' };
const grace = { name: 'Grace' };

console.log(introduce.call(ada, 'Hi', '!')); // → Hi, I'm Ada!
console.log(introduce.apply(ada, ['Hey', '.'])); // → Hey, I'm Ada.

const adaSaysHello = introduce.bind(ada, 'Hello'); // `this` and first arg fixed
console.log(adaSaysHello('?')); // → Hello, I'm Ada?
console.log(adaSaysHello.call(grace, '?')); // → Hello, I'm Ada? (bind wins)
console.log(adaSaysHello.bind(grace)('!')); // → Hello, I'm Ada! (first bind wins)
JavaScript

Passing null or undefined as the this argument gives you the global object in sloppy mode and exactly null or undefined in strict mode. Likewise, a primitive such as 5 is boxed into an object in sloppy mode but stays a primitive in strict mode.

4. new binding

Calling a function with new creates a fresh object, links it to the function’s prototype, runs the function with this set to that object, and returns it (unless the function explicitly returns a different object). The full sequence is in prototypes and inheritance.

function Person(name) {
  this.name = name;
}

const ada = new Person('Ada');
console.log(ada.name); // → Ada
JavaScript

Precedence when rules collide

When more than one rule could apply, the order is new > explicit (bind, call, apply) > implicit > default.

function getName() {
  return this.name;
}

const a = { name: 'a', getName };
const b = { name: 'b' };

console.log(a.getName()); // → a (implicit)
console.log(a.getName.call(b)); // → b (explicit beats implicit)

const bound = getName.bind(a);
const c = { name: 'c', bound };
console.log(c.bound()); // → a (bind beats implicit)

function Person(name) {
  this.name = name;
}
const BoundPerson = Person.bind({ name: 'ignored' });
console.log(new BoundPerson('Grace').name); // → Grace (new beats bind)
JavaScript

new on a bound function ignores the bound this but keeps any bound arguments. In practice you rarely rely on that; it simply settles the ordering.

Interview tip

Walk the checklist out loud: “Is it an arrow function? Then it’s the outer this. Called with new? The new object. call, apply or a bound function? The object passed in. Called as obj.method()? obj. Otherwise, undefined in strict mode or the global object in sloppy mode.”

Arrow functions: the exception

Arrow functions have no this of their own. They look it up lexically, like any other variable, from the scope they were defined in. None of the four rules applies: call, apply and bind still pass arguments but cannot change this, and new throws. That makes arrows ideal for callbacks inside methods:

const timer = {
  seconds: 0,
  start() {
    // the arrow has no `this` of its own; it uses start()'s `this`, i.e. timer
    const id = setInterval(() => {
      this.seconds += 1;
      console.log(this.seconds);
      if (this.seconds === 3) clearInterval(id);
    }, 100);
  },
};

timer.start(); // → 1 2 3
JavaScript

It also makes them the wrong choice for methods themselves:

const obj = {
  regular() {
    return this === obj;
  },
  arrow: () => this === obj, // `this` comes from the module scope, not obj
};

console.log(obj.regular()); // → true
console.log(obj.arrow()); // → false

const arrow = () => this;
console.log(arrow.call({ name: 'ignored' })); // → undefined (in an ES module)

try {
  new arrow();
} catch (e) {
  console.log(e.message); // → arrow is not a constructor
}
JavaScript

Gotcha

An object literal’s braces do not create a scope. An arrow function defined as a property takes this from wherever the object literal is written (the module, the global scope, or an enclosing function), never from the object.

Where this gets lost

Passing methods as callbacks

counter.increment evaluates to the function alone. Whoever calls it later decides this.

class Counter {
  count = 0;
  increment() {
    this.count += 1;
  }
}

const counter = new Counter();

try {
  [1, 2, 3].forEach(counter.increment); // passes the function, not the object
} catch (e) {
  console.log(e.message); // → Cannot read properties of undefined (reading 'count')
}

[1, 2, 3].forEach(() => counter.increment()); // call it on the object
console.log(counter.count); // → 3
JavaScript

Timers are sneakier because they do not throw. setTimeout(counter.increment, 0) calls the function with this set to window in browsers (even for strict-mode code) and to a Timeout object in Node. Either way, the count silently never changes.

Class methods as event handlers

addEventListener calls the listener with this set to the element it is attached to (event.currentTarget), not your instance:

// EventTarget works the same in Node 22 and the browser
class Toggle {
  isOn = false;

  constructor(button) {
    button.addEventListener('click', this.handleClick);
  }

  handleClick() {
    // `this` is the button (event.currentTarget), not the Toggle
    this.isOn = !this.isOn;
  }
}

const button = new EventTarget();
const toggle = new Toggle(button);
button.dispatchEvent(new Event('click'));

console.log(toggle.isOn); // → false (unchanged)
console.log(button.isOn); // → true (oops: a new property on the button)
JavaScript

There are three standard fixes. Declare the handler as an arrow class field (handleClick = () => { ... }), bind it once in the constructor (this.handleClick = this.handleClick.bind(this)), or wrap it at the call site (addEventListener('click', () => this.handleClick())). The first two create one function per instance; that is fine for components, less so for thousands of small objects. Arrow fields also live on the instance rather than the prototype, so subclasses cannot call them with super.handleClick() and tests cannot stub them on the prototype.

Predict the output

Each snippet runs as an ES module.

const obj = {
  whoAmI() {
    return this === obj ? 'obj' : String(this);
  },
};

const f = obj.whoAmI;

console.log(obj.whoAmI()); // 1
console.log((obj.whoAmI)()); // 2
console.log((0, obj.whoAmI)()); // 3
console.log(f()); // 4
JavaScript

Answer: obj, obj, undefined, undefined. Parentheses around a property access keep the object reference, so (2) is still a method call. The comma operator in (3) evaluates to the bare function value, which drops the object; that is why compiled code sometimes contains (0, fn)() when it deliberately wants no this.

const team = {
  name: 'Core',
  members: ['Ada', 'Linus'],
  withArrow() {
    return this.members.map((m) => `${m}@${this.name}`);
  },
  withFunction() {
    return this.members.map(function (m) {
      return `${m}@${this.name}`;
    });
  },
};

console.log(team.withArrow()); // 1
console.log(team.withFunction()); // 2
JavaScript

Answer: (1) logs [ 'Ada@Core', 'Linus@Core' ]. (2) throws TypeError: Cannot read properties of undefined (reading 'name'), because map calls the regular function with the default binding. Passing this as map’s second argument (thisArg) or using an arrow fixes it.

const a = { name: 'a', hello() { return this.name; } };
const b = { name: 'b' };
const bound = a.hello.bind(b);
b.hello = bound;
const c = { name: 'c', hello: b.hello };

console.log(a.hello(), b.hello(), c.hello(), bound.call(a));
JavaScript

Answer: a b b b. Only the first call uses implicit binding. Every other call goes through the bound function, and neither a method call nor call can override bind.

The interview answer

“For regular functions, this is determined by the call site, and I check four rules in order of precedence. If the function is called with new, this is the newly created object. If it’s called through call, apply or a bound function, it’s the object that was passed in. If it’s called as a method, obj.method(), it’s the object before the dot. Otherwise it’s the default binding: undefined in strict mode, the global object in sloppy mode.

Arrow functions are the exception: they don’t have their own this, so they use the this of the scope they were defined in, and call, apply or bind can’t change it. Most real bugs come from detaching a method, such as passing obj.method as a callback or event handler. The fixes are an arrow wrapper, bind, or an arrow class field.”

More in JavaScript

read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
read ✓JavaScript · easy

Hoisting, the TDZ and var vs let vs const

What the engine sets up before your code runs, why var reads as undefined while let throws, how the temporal dead zone works, and why const doesn't mean immutable.

~6 min readread →
esc