Keep a fluent builder's chain typed across a subclass
QueryBuilder exposes chainable methods. A subclass adds one more. Today the chain breaks: after the first call the subclass's own methods are no longer visible.
Requirements: implement where and orderBy so that new PagedQuery().where("a = 1").limit(10) compiles unchanged, without re-annotating the chain, without casts and without repeating the methods in the subclass. build() must still return the assembled string.
class QueryBuilder {
protected parts: string[] = [];
where(cond: string) {
// your code here
}
orderBy(col: string) {
// your code here
}
build(): string {
return this.parts.join(" ");
}
}
class PagedQuery extends QueryBuilder {
limit(n: number): this {
this.parts.push(`LIMIT ${n}`);
return this;
}
}
Write the implementation.
Declare the return type as this, not the class name: where(cond: string): this, returning this. As the polymorphic this type it denotes the receiver's own class, so the subclass's limit() stays reachable in the chain; naming QueryBuilder there would drop it at the first link.
- ✗Annotating the return type as the class name, which erases the subclass mid-chain
- ✗Re-declaring every chainable method in each subclass to restore its return type
- ✗Reaching for a self-referential generic base when
thisalready expresses it
- →What happens to the chain if one method returns
new QueryBuilder()instead ofthis? - →How would you make
build()unavailable untilwhere()has been called at least once?
The solution
class QueryBuilder {
protected parts: string[] = [];
where(cond: string): this { // ← this, not QueryBuilder
this.parts.push(`WHERE ${cond}`);
return this;
}
orderBy(col: string): this {
this.parts.push(`ORDER BY ${col}`);
return this;
}
build(): string {
return this.parts.join(" ");
}
}
class PagedQuery extends QueryBuilder {
limit(n: number): this {
this.parts.push(`LIMIT ${n}`);
return this;
}
}
const sql = new PagedQuery()
.where("a = 1") // this = PagedQuery
.limit(10) // ✅ the subclass method is visible
.orderBy("id") // this = PagedQuery
.build();
Why this rather than QueryBuilder
this in return position is the polymorphic this type: it denotes the type of the receiver at the call site. On a PagedQuery instance the inherited where is typed (cond: string) => PagedQuery, even though it is declared exactly once in the base.
class Broken {
protected parts: string[] = [];
where(c: string): Broken { this.parts.push(c); return this; } // ← class name
}
class BrokenPaged extends Broken {
limit(n: number): this { return this; }
}
new BrokenPaged().where("a").limit(10);
// ~~~~~ Property 'limit' does not exist on type 'Broken'
⚠️ The self-referential parameter class QueryBuilder<T extends QueryBuilder<T>> solves the same problem at the cost of a this as unknown as T assertion and infects every consumer's signature. this gives you the same thing for free.