this Types & Polymorphism
this is also a type
Section titled “this is also a type”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.
Fluent chaining with polymorphic this
Section titled “Fluent chaining with polymorphic this”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.
this parameters
Section titled “this parameters”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 hereThis catches a whole class of bugs where a method relies on a this it might not actually get.
The lost-this pitfall, in the types
Section titled “The lost-this pitfall, in the types”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 nothingBy 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 TS2684class 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();// @runclass Base { greet() { return "base"; } constructor() { console.log(this.greet()); }}class Derived extends Base { override greet() { return "derived"; }}new Derived();derivedDo 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 constructionconsole.log(d.greet()); // this call DOES get "derived" -- last write wins after construction// @runclass Base { greet = () => "base"; constructor() { console.log(this.greet()); }}class Derived extends Base { override greet = () => "derived";}const d = new Derived();console.log(d.greet());basederivedDuring 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 TS2855class 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()); 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”Fix for lost this | Benefit | Cost |
|---|---|---|
this parameter (declare-only) | Zero runtime cost; turns a silent runtime crash into a compile error at the exact unsafe call site | Doesn’t fix anything by itself — it only makes the existing bug visible to the checker |
| Arrow-function field | Actually fixes the binding — this is always correct no matter how the field is called | One 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 site | No change to the class itself | Easy 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 |
Common Misconceptions
Section titled “Common Misconceptions”- 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.
overrideon an arrow field gives the same guaranteeoverridegives 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:TS2855reports this directly, because there is no prototype slot backing an arrow field forsuperto resolve against.
Reading AI-generated code
Section titled “Reading AI-generated code”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 forTS2855specifically — 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 (ifthisdoesn’t actually need lexical binding there) or to compose behavior explicitly instead of calling throughsuper. - 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 theuseDefineForClassFieldsinit-order bug from the previous lesson, just triggered by field-vs-field shadowing instead of an externalObject.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.