skip to content

Polymorphic this & Fluent APIs

Returning the polymorphic `this` type so chained calls keep the subclass type instead of collapsing to the base class. This is the trick behind type-safe builders and fluent APIs, and a common design-flavoured interview question.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

In TypeScript, a base class declares `where(sql: string): QueryBuilder` and `class UserQuery extends QueryBuilder` adds `withRoles()`. Why does `new UserQuery().where('id = 1').withRoles()` fail to compile, and what single change fixes it?

level: middleimportance: must knowfreq 62%

answer

  1. the annotation, not the runtime, loses it
  2. chain narrows to the base class
  3. an implicit type parameter per class
  4. resolved at each call site
  5. one signature, accurate for every subclass

basics

~20 s

The annotation is the bug. where is declared to return QueryBuilder, so the chain collapses to the base type and withRoles is not on it. Declare the return type as this, the polymorphic this type, and the subclass survives the chain.

solid answer

~40 s

The problem is the hand-written return type. `where` says it returns `QueryBuilder`, so the moment you call it the expression's type is the base class, and `withRoles` — declared only on `UserQuery` — is not a member of it. The compiler is being accurate: nothing in that signature promises the subclass comes back. Change the annotation to `where(sql: string): this` and it does. The polymorphic `this` type behaves like an implicit type parameter bounded by the declaring class, instantiated with the type of the receiver, so on a `UserQuery` the call evaluates to `UserQuery` and the chain keeps every subclass member. The same works in interfaces and abstract declarations, and it changes nothing about the emitted JavaScript — the type layer is erased either way.

code

typescript · 21 lines
typescript
class QueryBuilder {
  private parts: string[] = [];

  where(sql: string): this {
    this.parts.push(`WHERE ${sql}`);
    return this;
  }

  build(): string {
    return this.parts.join(' ');
  }
}

class UserQuery extends QueryBuilder {
  withRoles(): this {
    return this.where('role IS NOT NULL');
  }
}

const sql = new UserQuery().where('id = 1').withRoles().build();
console.log(sql);

go deeper

for a junior

Recognise the error message shape: the chained expression is typed as the base class, so a subclass-only method is not found. Know that annotating the return type as this is the fix.

for a middle

Explain that this is an implicit type parameter bounded by the declaring class and instantiated with the receiver's type, and that a body of return this would have inferred it without any annotation.

for a senior

Demonstrate the assignability asymmetry and its consequence: this is assignable to the class but not the reverse, which is why a method typed this cannot hand back a freshly built base instance.

for a principal

Own the library-design angle: publishing base-class return types in a hierarchy meant for extension silently breaks every downstream subclass's chain, and fixing it later is a signature change across the public surface.

## Where the chain collapses ```ts class QueryBuilder { where(sql: string): QueryBuilder { /* ... */ return this; } } class UserQuery extends QueryBuilder { withRoles(): UserQuery { return this; } } new UserQuery().where('id = 1').withRoles(); // error: Property 'withRoles' does not exist on type 'QueryBuilder'. ``` At runtime this works perfectly — `where` returns the same `UserQuery` object, which certainly has `withRoles` on its prototype chain. The failure is a modelling failure: the declared signature says `QueryBuilder`, and the checker reasons from the declaration, not from what the body happens to do. Once the expression's type is `QueryBuilder`, every subclass member is invisible for the rest of the chain, no matter what order the calls come in. ## `this` in type position TypeScript lets you write `this` where a type is expected. That is the **polymorphic `this` type**, and it is a different thing from the `this` value inside the body. Think of it as an implicit type parameter that every class declaration carries, constrained by the class itself, and instantiated with the type of the receiver at each call site: ```ts class QueryBuilder { private parts: string[] = []; where(sql: string): this { this.parts.push(`WHERE ${sql}`); return this; } } ``` Called on a `QueryBuilder`, `where` returns `QueryBuilder`. Called on a `UserQuery`, the very same declaration returns `UserQuery`. One signature, inherited unchanged, that stays accurate for every subclass anyone writes later — including subclasses that did not exist when the base was written. ## Walking the fixed example ```ts class UserQuery extends QueryBuilder { withRoles(): this { return this.where('role IS NOT NULL'); } } new UserQuery().where('id = 1').withRoles(); // ok ``` `new UserQuery()` has type `UserQuery`; `where` therefore yields `UserQuery`; `withRoles` is found. Note the subclass method also returns `this` rather than `UserQuery` — that keeps the chain intact for anyone who subclasses `UserQuery` in turn. And note that `this.where(...)` inside `withRoles` type-checks: it returns `this`, which is exactly what the signature promises. ## Assignability runs one way Because `this` is bounded by the declaring class, `this` is assignable to `QueryBuilder` — a `this` value is always at least a `QueryBuilder`. The reverse is not true: a plain `QueryBuilder` is **not** assignable to `this`, since `this` might stand for some subclass with extra members. That asymmetry is the whole reason the type is useful, and it is also why a method typed `(): this` cannot simply hand back a freshly constructed base instance. ## You usually get it for free If the body is just `return this;` and you leave the return type off, the compiler infers `this` on its own. In real code the collapsed chain almost always traces back to someone helpfully writing the class name by hand, or to a `.d.ts` written that way. Reviewing a fluent class, the question to ask is not "is the return type annotated?" but "does the annotation say `this`?" ## Interfaces and abstract members The polymorphic `this` type is not limited to class bodies: ```ts interface Resettable { reset(): this; } class Session implements Resettable { reset(): this { return this; } } ``` Each implementer instantiates `this` with its own type, so an interface can describe a fluent contract without knowing any of the classes that will satisfy it. ## The promise it makes `(): this` claims the returned value *is* the receiver's own type. That holds trivially when the body returns `this`. It does not hold for a method that builds a new object, and the compiler will say so — a separate problem worth knowing about before you reach for `this` on a copy-on-write API. ## Erasure Swapping `QueryBuilder` for `this` in the annotation changes the emitted JavaScript not at all. There is no runtime marker, no helper, no reflection: the subclass came back either way, and the only difference is whether the checker was willing to admit it.

  • Which way does assignability run between `this` and the declaring class type?
    `this` is assignable to the class type, because it is bounded by it — a `this` value is always at least a `QueryBuilder`. The reverse fails: a plain `QueryBuilder` is not assignable to `this`, since `this` could stand for a subclass with additional members that the base instance lacks.
  • Does switching the annotation from the class name to `this` change the emitted JavaScript?
    No. Both compile to the same `return this;`. The runtime always handed back the subclass instance; only the checker's view differed. This is the erasure point interviewers are usually fishing for — the fix is entirely a compile-time correction.
  • Can an interface declare a fluent method that preserves the implementing type?
    Yes — `interface Resettable { reset(): this; }`. Each implementing class instantiates `this` with its own type, so a class implementing it can chain `reset()` and still see its own members. Abstract members work the same way; both need the annotation written out because there is no body to infer from.

saying these in an interview costs you the question

  • Says the runtime loses the subclass, so a cast is needed
  • Claims each chained call constructs a new base instance
  • Thinks the base class type is assignable to this
  • Believes overriding the method in every subclass is the only fix
  • Assumes the failure depends on a strictness compiler flag

context

open as a page

In TypeScript, `class User { name = ''; setName(n: string) { this.name = n; } }` makes `new User().setName('Ada').setAge(36)` fail to compile. What must a chainable method do, and what return type does the compiler then infer?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Chaining requires the method to end with return this. With no return statement the method is inferred as returning void, and void has no members. Once it returns the receiver, TypeScript infers the polymorphic this type.

open as a page

In a TypeScript class, what does declaring a method as `isDirectory(): this is Directory` give you that declaring it `isDirectory(): boolean` does not?

level: middleimportance: should knowfreq 32%

basics

~20 s

A this-based type predicate narrows the receiver: inside a branch where the call returned true, the compiler treats the object the method was called on as a Directory. A plain boolean return narrows nothing at all.

open as a page

In TypeScript, `class Base { clone(): this { return new Base(); } }` does not compile — the returned Base is not assignable to `this`. Why is the compiler right, and what are the realistic ways to type a clone or copy-on-write method?

level: seniorimportance: should knowfreq 24%

basics

~20 s

The compiler is right: this stands for the receiver's actual class, which may be a subclass with extra members, so a freshly constructed Base does not satisfy it. Return this only when you genuinely hand back the receiver; a constructed copy needs an explicit assertion.

open as a page