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;
}
}
thisin return position — tells the compiler the method returns “whatever type the method was called on,” enabling correct chaining through subclasses.thisin parameter position — constrains an argument to be assignable to the exact subtype of the calling instance, not just the declaring base class.thistypes are only legal inside a non-static member of aclassorinterface. 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
- When it sees
method(): thisinside classBase, the compiler records that the method’s return type is bound to “the receiver type” rather than a fixed name. - When a subclass
Sub extends Baseinherits (does not override) that method, and code callssubInstance.method(), the compiler substitutes the receiver type withSubat the call site — producing a return type ofSub, complete with any membersSubadded. - 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. - If a subclass overrides a method that returns
this, the override must still be compatible — typically by also declaringthisas the return type, preserving the polymorphic behavior down the hierarchy. - 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 whiletscis 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
thisas 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 likeequals,merge, orclonewhere 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 asthistoo, 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
thisbugs usually only surface once a second subclass tries to extend the chain.
Practice Exercises
- Write a chainable
Stack<T>class withpush(item: T): thisand a non-chainablepop(): T | undefined. Then create aLoggingStack<T>subclass that adds apeek(): T | undefinedmethod, and confirm you can chain.push(1).push(2)on aLoggingStackinstance and still call.peek()afterward. - 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. - Add an
isEqualTo(other: this): booleanmethod to aPointclass withxandyfields. Explain, in your own words, why callingsomePoint.isEqualTo(someUnrelatedObject)from a class that does not extendPointfails to compile, even if that object happens to have matchingx/yproperties at runtime.
Summary
thisused 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,
thislets chainable methods stay correctly typed after subclassing, without redeclaring them in every subclass. - As a parameter type,
thisrestricts an argument to be the same subtype as the calling instance, catching cross-subclass mix-ups at compile time. thistypes are only valid inside non-static class or interface members — free functions need an explicitthis: Typeparameter instead.- Like every TypeScript type, polymorphic
thisis completely erased at compile time; it exists purely to help the type checker, and has no effect on the emitted JavaScript or runtime behavior.
