Skip to content

this Types & Polymorphism

Inside a class, this is not only a value — it is a type that means “the type of the current instance.” Crucially, in a subclass this refers to the subclass, not the class where the method was written. This is the polymorphic this type, and it makes fluent APIs work correctly across inheritance.

A builder that returns this from each method lets calls chain. Typing the return as this (not the class name) means subclass methods stay chainable too.

class QueryBuilder {
private parts: string[] = [];
where(cond: string): this { // return type is `this`, not QueryBuilder
this.parts.push(cond);
return this;
}
}
class MySQLBuilder extends QueryBuilder {
limit(n: number): this {
return this;
}
}
new MySQLBuilder()
.where("a = 1") // returns MySQLBuilder (because of `this`), so...
.limit(10); // ...limit() is still available. ✅

If where had been typed to return QueryBuilder, the chain would “forget” it was a MySQLBuilder and .limit() would fail. Polymorphic this preserves the real type through the chain.

A function can declare a fake first parameter named this to type what this must be when the function is called. It is erased on emit and does not affect the real argument list — it exists purely to type-check the calling context.

interface Card { title: string; }
function render(this: Card) {
return this.title.toUpperCase();
}
// render(); // ❌ The 'this' context of type 'void' is not assignable...
render.call({ title: "hi" }); // ✅ this is a Card here

This catches a whole class of bugs where a method relies on a this it might not actually get.

The classic JavaScript footgun — passing a method as a callback and losing its this — has a type-level story too. When you pass obj.method by reference, the this binding is dropped:

class Counter {
count = 0;
increment() { this.count++; } // relies on `this`
}
const c = new Counter();
const fn = c.increment;
fn(); // 💥 at runtime `this` is undefined — and the type system says nothing

By default the type system does not catch this: increment never declared what this must be, so fn() type-checks with zero diagnostics even though it crashes at runtime. strictBindCallApply does not add that check for you — it only makes .call/.apply/.bind themselves verify their this argument against a this parameter the method already declares. To get a compile-time warning here, the method has to declare its required this explicitly:

// @expect-error TS2684
class Counter {
count = 0;
increment(this: Counter) { this.count++; }
}
const c = new Counter();
const fn = c.increment;
fn();

Now the mismatch is caught: TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'. The idiomatic fixes are an arrow-function field (increment = () => { ... }, which binds this lexically) or binding at the call site (c.increment.bind(c)).

The arrow-field fix, and what it actually costs

Section titled “The arrow-field fix, and what it actually costs”

An arrow-function field genuinely fixes the lost-this bug — arrow functions don’t have their own this, so this inside one is captured lexically from where the field was defined, which is always the constructor, which always has the real instance bound. But “arrow field” is not just a syntax variant of “method” — it is a different kind of class member entirely, with different runtime placement and different rules for two things classes normally offer for free: overriding and super.

Per-instance allocation, not per-prototype

Section titled “Per-instance allocation, not per-prototype”

An ordinary method lives once, on the prototype, shared by every instance. An arrow-function field is a field — it gets its own closure allocated fresh in the constructor of every single instance. The emitted JavaScript makes this concrete:

class Counter {
count = 0;
increment = () => { this.count++; }; // arrow-function field
}

Compiling with tsc --target es2025 (node_modules/typescript/bin/tsc --ignoreConfig --target es2025 --module esnext counter.ts) emits exactly what was written, inside the (implicit) constructor:

"use strict";
class Counter {
count = 0;
increment = () => { this.count++; }; // arrow-function field
}

That looks harmless until you compare it with a real method: a method compiles to one function, defined once on Counter.prototype, that every new Counter() shares by reference. An arrow field compiles to an assignment that runs inside every constructor call, allocating a brand-new closure object per instance. A thousand Counter instances share one increment method; a thousand Counter instances with an arrow field increment allocate a thousand separate closures, each capturing its own this. For a class instantiated rarely, this is immaterial. For a class instantiated at scale (a node in a large tree, a row in a virtualized list), it is real, measurable extra memory per instance — the trade a codebase makes for this-safety at the class-field level.

It can be shadowed, but it can’t really be overridden

Section titled “It can be shadowed, but it can’t really be overridden”

override is syntactically accepted on an arrow field, because it is just a field re-declaration and the checker doesn’t forbid the keyword there — but it doesn’t provide the same guarantee a method override does. A real method lives on the prototype, so a base class calling this.method() in its own constructor genuinely dispatches to the subclass’s override, even during construction:

class Base {
greet() { return "base"; }
constructor() {
console.log(this.greet()); // real prototype method -- genuinely virtual/polymorphic
}
}
class Derived extends Base {
override greet() { return "derived"; }
}
new Derived();
// @run
class Base {
greet() { return "base"; }
constructor() {
console.log(this.greet());
}
}
class Derived extends Base {
override greet() { return "derived"; }
}
new Derived();
derived

Do the identical thing with arrow fields, and the base constructor sees the base’s own field — because Derived’s field assignment hasn’t run yet when super()’s body (Base’s constructor) executes:

class Base {
greet = () => "base";
constructor() {
console.log(this.greet()); // calls Base's own arrow field -- not "virtual"
}
}
class Derived extends Base {
override greet = () => "derived";
}
const d = new Derived(); // logs during construction
console.log(d.greet()); // this call DOES get "derived" -- last write wins after construction
// @run
class Base {
greet = () => "base";
constructor() {
console.log(this.greet());
}
}
class Derived extends Base {
override greet = () => "derived";
}
const d = new Derived();
console.log(d.greet());
base
derived

During Base’s constructor, this.greet() prints "base" — the opposite of the real-method version above. Only after construction finishes, once Derived’s own field assignment has run and shadowed Base’s, does .greet() return "derived". This is the same [[Define]]-semantics ordering the class-fields init-order bug in the previous lesson is built on: an arrow field is “overridden” only in the sense that the later assignment wins for calls made after both constructors finish — never in the sense of true prototype-based virtual dispatch during construction.

It genuinely cannot be called through super

Section titled “It genuinely cannot be called through super”

A real overridden method can always reach the base implementation with super.method(). An arrow field cannot — there is no prototype slot for super to look up, only an instance property, and TypeScript rejects the attempt with a dedicated diagnostic:

// @expect-error TS2855
class Base {
greet = () => "base";
}
class Derived extends Base {
greet = () => super.greet() + "-derived";
}
error TS2855: Class field 'greet' defined by the parent class is not accessible in the child class via super.

TS2855 exists specifically for this case — it is not a generic “property doesn’t exist” error, but a diagnostic that knows exactly why: greet is a class field on the parent, not a prototype method, so there is nothing for super to find.

Real method vs. arrow field: virtual dispatch during construction

class Base {
greet = () => "base";
constructor() {
console.log("during Base constructor:", this.greet());
}
}
class Derived extends Base {
override greet = () => "derived";
}
const d = new Derived();
console.log("after construction:", d.greet());
Open in TS Playground

Click Run: during Base constructor: base, then after construction: derived. Change both arrow fields back to real methods (greet() { return "base"; } / override greet() { return "derived"; }) and click Run again — the first line becomes derived too, because a real method dispatches virtually from the moment the subclass exists, not only after its own initializer has run.

Every fix in this lesson trades one guarantee for another. this parameters trade nothing — they’re a pure compile-time check with zero runtime cost, which is why declaring one is free and should be done whenever a method’s correctness genuinely depends on its this. The arrow-field fix for lost this trades memory (one closure per instance instead of one method per class) and trades away two things real methods give for free: virtual dispatch during a base constructor’s own execution, and reachability through super. Neither trade is wrong — but treating an arrow field as “just a method with different syntax” is exactly the misconception that produces surprising bugs when a base class’s constructor calls it, or a subclass tries to extend it with super.

Fix for lost thisBenefitCost
this parameter (declare-only)Zero runtime cost; turns a silent runtime crash into a compile error at the exact unsafe call siteDoesn’t fix anything by itself — it only makes the existing bug visible to the checker
Arrow-function fieldActually fixes the binding — this is always correct no matter how the field is calledOne closure allocated per instance (not shared on the prototype); can’t be reached through super; only shadowed, not virtually dispatched, during a base constructor
.bind() at the call siteNo change to the class itselfEasy to forget at any one of many call sites; still a method on the prototype otherwise, so this fix must be repeated everywhere the method is passed as a callback
  • An arrow-function field is just a method written with different syntax. It’s a field, allocated fresh per instance in the constructor, not a shared prototype method — the two have different memory costs and different override semantics, not just different syntax.
  • override on an arrow field gives the same guarantee override gives a real method. It’s accepted syntactically and does shadow the base field for calls made after construction finishes, but it does not provide virtual dispatch during the base class’s own constructor — the base still sees its own field at that point, because the subclass’s field assignment hasn’t run yet.
  • Since arrow fields can be reassigned like any field, super.method() should still reach the base version. It cannot: TS2855 reports this directly, because there is no prototype slot backing an arrow field for super to resolve against.

Arrow-function class fields are a very common generated-code pattern for “fixing” React-style or event-handler this issues — for good reason, they usually work. But two follow-on mistakes are common once a codebase has several:

  • A subclass tries to super.method() an arrow field it’s overriding, because the surrounding code looks exactly like normal method overriding. Watch for TS2855 specifically — it means the member being extended is a field, not a method, and the fix is almost always to convert both the base and the subclass member back to real methods (if this doesn’t actually need lexical binding there) or to compose behavior explicitly instead of calling through super.
  • A base class’s constructor calls an arrow-field “hook” expecting subclass customization to already be in effect, mirroring the classic template-method pattern that works fine with real methods. Because arrow fields are only assigned after super() returns, this silently runs the base’s own version during construction — the same underlying ordering issue as the useDefineForClassFields init-order bug from the previous lesson, just triggered by field-vs-field shadowing instead of an external Object.assign.

🐛 Spot the bug

class Widget {
onMount = () => {
console.log("Widget.onMount");
};
constructor() {
this.onMount(); // "hook" pattern -- expects the most-derived onMount to run
}
}
class Dialog extends Widget {
override onMount = () => {
console.log("Dialog.onMount");
};
}
new Dialog();
Show the answer

This prints Widget.onMount, not Dialog.onMount — which reads as a bug to anyone expecting the template-method pattern that works with real methods. Widget’s constructor calls this.onMount() while super() (here, Widget’s own constructor body) is still running, which is before Dialog’s own field assignments happen. At that exact moment, this.onMount is still Widget’s own arrow field — Dialog’s override onMount hasn’t been assigned yet, because arrow fields are assigned by the subclass constructor only after super() returns. The fix is to make onMount a real method (onMount() { ... } / override onMount() { ... }) if the design genuinely needs virtual dispatch during construction — real methods live on the prototype from the moment the class is defined, so this.onMount() in Widget’s constructor already sees Dialog’s override.

What does the this type mean inside a class method?
Why return this (rather than the class name) from a chainable builder method?
What happens by default (no this parameter declared)?
class Counter {
  count = 0;
  increment() { this.count++; }
}
const c = new Counter();
const fn = c.increment;
fn();
What does this log, and why?
class Base {
  greet = () => "base";
  constructor() { console.log(this.greet()); }
}
class Derived extends Base {
  override greet = () => "derived";
}
new Derived();
Why does super.greet() fail with TS2855 when greet is an arrow-function class field in both the base and the subclass?
A generated class uses an arrow-function field as a "hook" the base class calls from its own constructor, expecting the most-derived override to run (the template-method pattern). What actually happens, and what is the fix?