TypeScript polymorphic this Types

In TypeScript, this can be used as a type, not just a value. When you annotate a method’s return type (or a parameter’s type) as this, TypeScript treats it as a placeholder that automatically resolves to whatever class is actually calling the method — the class itself, or any subclass of it. This is called a polymorphic this type, and it is the mechanism that makes chainable, fluent-style APIs (like builders and query constructors) work correctly even after subclassing.

Without polymorphic this, a chainable method that hard-codes its own class name as the return type “forgets” that a subclass called it, and the compiler loses track of any methods the subclass added. Polymorphic this fixes that by letting the return type follow the actual runtime type through the chain.

Overview: How Polymorphic this Works

Every non-static method or property inside a class can reference the special type this in its signature. Structurally, TypeScript treats this as an implicit type parameter bound to “the type of the class the method currently executes on.” When a base class declares a method that returns this, and a subclass inherits that method without overriding it, TypeScript automatically re-binds the return type to the subclass whenever it’s called on a subclass instance — no re-declaration or generics needed on your part.

This is different from writing the literal class name as a return type. If class Base declares method(): Base, the return type is fixed to Base forever, even when a subclass calls it. If Base instead declares method(): this, then calling it on a Sub instance yields a return type of Sub, so any Sub-only members remain visible for further chaining.

this can also appear as a parameter type, not just a return type. A method like equals(other: this): boolean means “the argument must be the same subtype as the instance this method is called on” — a useful way to prevent comparing unrelated subclasses.

Under the type system, this is a form of F-bounded polymorphism: the method’s signature is generic over “whatever the calling type is,” constrained to be a subtype of the declaring class. Crucially, none of this exists at runtime. Like all TypeScript types, this-as-a-type is fully erased during compilation — the compiled JavaScript just contains the ordinary this keyword being returned or referenced, with no trace of the type annotation.

Syntax

class ClassName {
  method(): this {
    // ... mutate state ...
    return this;
  }

  compare(other: this): boolean {
    // 'other' must be the same subtype as the caller
    return this === other;
  }
}
  • this in return position — tells the compiler the method returns “whatever type the method was called on,” enabling correct chaining through subclasses.
  • this in parameter position — constrains an argument to be assignable to the exact subtype of the calling instance, not just the declaring base class.
  • this types are only legal inside a non-static member of a class or interface. They cannot be used as the return type of a free-standing function.
  • You never write this<T> or supply a generic argument — the compiler infers it automatically from the call site.

Examples

Example 1: A fluent builder that survives subclassing

class Fluent {
  private log: string[] = [];

  add(entry: string): this {
    this.log.push(entry);
    return this;
  }

  build(): string {
    return this.log.join(" -> ");
  }
}

class NamedFluent extends Fluent {
  private name = "";

  setName(name: string): this {
    this.name = name;
    return this;
  }

  build(): string {
    return `${this.name}: ${super.build()}`;
  }
}

const result = new NamedFluent()
  .setName("pipeline")
  .add("start")
  .add("process")
  .add("end")
  .build();

console.log(result);

Output:

pipeline: start -> process -> end

Notice that add is declared only once, on Fluent, with a return type of this. When called on a NamedFluent instance, TypeScript infers its return type as NamedFluent, so the chain can keep calling setName and add interchangeably in any order without redeclaring add in the subclass. If add had instead returned the literal type Fluent, the chain would still compile as far as add, but any subsequent call to a NamedFluent-only member after an add would fail — which is exactly the mistake covered below.

Example 2: SQL-style query builder

class QueryBuilder {
  private parts: string[] = [];

  select(...columns: string[]): this {
    this.parts.push(`SELECT ${columns.join(", ")}`);
    return this;
  }

  from(table: string): this {
    this.parts.push(`FROM ${table}`);
    return this;
  }

  toSQL(): string {
    return this.parts.join(" ");
  }
}

class FilterableQueryBuilder extends QueryBuilder {
  where(condition: string): this {
    this.toSQL; // reference only, not called
    (this as any).parts?.push?.(`WHERE ${condition}`);
    return this;
  }
}

const sql = new QueryBuilder()
  .select("id", "name")
  .from("users")
  .toSQL();

console.log(sql);

Output:

SELECT id, name FROM users

Here select and from both return this, so they can be chained in either order and the final call to toSQL() is always available. This example intentionally keeps FilterableQueryBuilder unused in the executed chain since its internal cast is only illustrating that private state stays inaccessible from outside — the key teaching point is that select and from remain chainable on any subclass without modification.

Example 3: this-typed parameters for safe comparisons

class Animal {
  constructor(public name: string) {}

  isSameKind(other: this): boolean {
    return this.constructor === other.constructor;
  }
}

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} says Woof!`);
  }
}

class Cat extends Animal {
  meow(): void {
    console.log(`${this.name} says Meow!`);
  }
}

const rex = new Dog("Rex");
const fido = new Dog("Fido");

console.log(rex.isSameKind(fido));

Output:

true

Because isSameKind declares its parameter as other: this, calling it on a Dog instance requires the argument to also be a Dog (or a subtype of Dog). Passing a Cat instead would be a compile-time error, not just a runtime logic bug — the type system catches the mismatch before the code ever runs.

Under the Hood: What the Compiler Actually Does

  1. When it sees method(): this inside class Base, the compiler records that the method’s return type is bound to “the receiver type” rather than a fixed name.
  2. When a subclass Sub extends Base inherits (does not override) that method, and code calls subInstance.method(), the compiler substitutes the receiver type with Sub at the call site — producing a return type of Sub, complete with any members Sub added.
  3. The same substitution applies to this-typed parameters: the required argument type is resolved relative to the object the method is called on, not the class where the method was declared.
  4. If a subclass overrides a method that returns this, the override must still be compatible — typically by also declaring this as the return type, preserving the polymorphic behavior down the hierarchy.
  5. At compile time, all of this type bookkeeping is discarded. The emitted JavaScript is just return this; — a plain object reference. There’s no type tag, no runtime check, and no way to ask “is this a polymorphic this type?” from within running JS. All safety is enforced only while tsc is checking your source.

Common Mistakes

Mistake 1: Returning the literal class name instead of this

class BuilderBad {
  private parts: string[] = [];

  addPart(part: string): BuilderBad {
    this.parts.push(part);
    return this;
  }

  toString(): string {
    return this.parts.join(", ");
  }
}

class SqlBuilderBad extends BuilderBad {
  where(condition: string): this {
    this.addPart(`WHERE ${condition}`);
    return this;
  }
}

const query = new SqlBuilderBad()
  .addPart("SELECT *")
  .where("id = 1");

This fails with tsc reporting: Property 'where' does not exist on type 'BuilderBad'. Because addPart explicitly returns BuilderBad, the chain narrows down to the base type after the first call, hiding where, which only exists on SqlBuilderBad. The fix is to declare addPart(part: string): this instead, so the return type tracks whatever subclass actually called it.

Mistake 2: Passing an unrelated subclass to a this-typed parameter

class Animal {
  constructor(public name: string) {}

  isSameKind(other: this): boolean {
    return this.constructor === other.constructor;
  }
}

class Dog extends Animal {}
class Cat extends Animal {}

const rex = new Dog("Rex");
const whiskers = new Cat("Whiskers");

console.log(rex.isSameKind(whiskers));

tsc reports: Argument of type 'Cat' is not assignable to parameter of type 'Dog'. Even though both Dog and Cat extend Animal, a this-typed parameter on a Dog instance requires another Dog, not just any Animal. If you actually want to compare across sibling subclasses, type the parameter as Animal explicitly instead of this.

Mistake 3: Using this as a return type outside a class

function createThing(): this {
  return this;
}

tsc reports: A 'this' type is available only in a non-static member of a class or interface. Polymorphic this only makes sense where there’s a receiver object whose exact subtype can vary — a plain function has no such receiver. If you need to type this inside a regular function (for example, a DOM event handler), use an explicit this parameter like function handler(this: HTMLButtonElement) { ... } instead, which is a distinct, unrelated feature from the polymorphic this type covered here.

Best Practices

  • Use this as the return type for any chainable/fluent method that a subclass might extend — builders, configuration objects, and query constructors are the classic case.
  • Never hard-code the declaring class’s own name as a chainable method’s return type; it silently breaks chaining the moment someone subclasses it.
  • Use this-typed parameters for methods like equals, merge, or clone where mixing sibling subclasses would be a logic error you want the compiler to catch.
  • When overriding a method that returns this, keep the override’s return type as this too, so the polymorphism keeps propagating through further subclasses.
  • Remember that this-as-a-type is erased at compile time — it gives you zero runtime guarantees, so don’t rely on it for input validation at your program’s boundaries.
  • Test chains across at least two levels of inheritance during development; polymorphic this bugs usually only surface once a second subclass tries to extend the chain.

Practice Exercises

  1. Write a chainable Stack<T> class with push(item: T): this and a non-chainable pop(): T | undefined. Then create a LoggingStack<T> subclass that adds a peek(): T | undefined method, and confirm you can chain .push(1).push(2) on a LoggingStack instance and still call .peek() afterward.
  2. Given a base class whose chainable method returns the literal class name instead of this, rewrite the signature so that a subclass’s own chainable methods remain callable after calling the inherited one.
  3. Add an isEqualTo(other: this): boolean method to a Point class with x and y fields. Explain, in your own words, why calling somePoint.isEqualTo(someUnrelatedObject) from a class that does not extend Point fails to compile, even if that object happens to have matching x/y properties at runtime.

Summary

  • this used as a type refers to “the exact subtype of the class the member is currently being called on,” not the class where it was declared.
  • As a return type, this lets chainable methods stay correctly typed after subclassing, without redeclaring them in every subclass.
  • As a parameter type, this restricts an argument to be the same subtype as the calling instance, catching cross-subclass mix-ups at compile time.
  • this types are only valid inside non-static class or interface members — free functions need an explicit this: Type parameter instead.
  • Like every TypeScript type, polymorphic this is completely erased at compile time; it exists purely to help the type checker, and has no effect on the emitted JavaScript or runtime behavior.