A fluent API is one where each call returns something you can keep calling: query.where('active').limit(10).toSQL(). Writing one in TypeScript is easy until someone subclasses it, extracts a method into a variable, or reuses half a chain. Each of those has a specific TypeScript feature attached, and each shows up in interviews about class design: the polymorphic this type, this parameters, and this-based type guards.
Why returning the class name breaks subclasses
Start with a small SQL builder. Each method records a clause and returns the builder:
class QueryBuilder {
protected parts: string[] = [];
constructor(private table: string) {}
where(clause: string): QueryBuilder {
this.parts.push(`WHERE ${clause}`);
return this;
}
limit(n: number): QueryBuilder {
this.parts.push(`LIMIT ${n}`);
return this;
}
toSQL(): string {
return [`SELECT * FROM ${this.table}`, ...this.parts].join(' ');
}
}
class PgQueryBuilder extends QueryBuilder {
returning(cols: string): this {
this.parts.push(`RETURNING ${cols}`);
return this;
}
}
const sql = new PgQueryBuilder('users').where('active').returning('id').toSQL();q1.ts(24,57): error TS2339: Property 'returning' does not exist on type 'QueryBuilder'.At runtime this would work, because where returns the same PgQueryBuilder object. The type annotation is what’s wrong. where promises to return a QueryBuilder, so after the first call TypeScript has forgotten that the object is a PgQueryBuilder, and returning is gone. People sometimes “fix” this by overriding every base method in the subclass just to narrow the return type, which defeats the purpose of inheriting.
The polymorphic this type
Inside a class, this can be used as a type. It means “the type of whatever object this method was called on”. A method declared to return this returns a PgQueryBuilder when called on one, and a QueryBuilder when called on a base instance:
class QueryBuilder {
protected parts: string[] = [];
readonly table: string;
constructor(table: string) {
this.table = table;
}
where(clause: string): this {
this.parts.push(`WHERE ${clause}`);
return this;
}
limit(n: number): this {
this.parts.push(`LIMIT ${n}`);
return this;
}
toSQL(): string {
return [`SELECT * FROM ${this.table}`, ...this.parts].join(' ');
}
}
class PgQueryBuilder extends QueryBuilder {
returning(cols: string): this {
this.parts.push(`RETURNING ${cols}`);
return this;
}
}
const sql = new PgQueryBuilder('users').where('active').limit(10).returning('id').toSQL();
console.log(sql);
const base = new QueryBuilder('orders').where('total > 100');
const reused = base.limit(5);
console.log(base === reused, base.toSQL());
const { toSQL } = new QueryBuilder('audit');
try {
toSQL();
} catch (e) {
console.log((e as Error).message);
}This compiles under --strict. (The parameter property private table from the first version is gone because Node’s built-in type stripping can’t run parameter properties, and this file runs directly with node.) The output:
SELECT * FROM users WHERE active LIMIT 10 RETURNING id
true SELECT * FROM orders WHERE total > 100 LIMIT 5
Cannot read properties of undefined (reading 'table')The first line is the fix working: the chain stays a PgQueryBuilder through where and limit. The second and third lines are two new bugs, and the compiler said nothing about either of them.
One rule about the this type: the only value that has type this is this itself (or something derived from it). You can’t write return new QueryBuilder(this.table) from a method declared to return this, because a QueryBuilder isn’t necessarily the subclass the caller has. That restriction is what makes the immutable version below a little more work.
Detached methods and this parameters
The third output line came from const { toSQL } = new QueryBuilder('audit'). Destructuring copies the function, not the object it came from. When toSQL() runs, this is undefined (class bodies are strict mode), and reading this.table throws. TypeScript let it compile because, by default, methods don’t say anything about what this must be.
You can say it. A parameter literally named this, listed first, declares the required receiver. It’s erased from the emitted JavaScript and doesn’t count as an argument:
class Query {
readonly table: string;
constructor(table: string) {
this.table = table;
}
toSQL(this: Query): string {
return `SELECT * FROM ${this.table}`;
}
}
const { toSQL } = new Query('audit');
toSQL();q3.ts(11,1): error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Query'.The same mechanism works from the other side. A function that accepts a callback and calls it without a receiver can declare the callback as (this: void, ...) => void. Then passing a method that needs its object is an error:
function onEach(items: string[], cb: (this: void, item: string) => void) {
items.forEach((i) => cb(i));
}
class Logger {
prefix = '[app]';
log(this: Logger, msg: string) { console.log(this.prefix, msg); }
logBound = (msg: string) => { console.log(this.prefix, msg); };
}
const logger = new Logger();
onEach(['a'], logger.log);
onEach(['b'], logger.logBound);q4.ts(10,15): error TS2345: Argument of type '(this: Logger, msg: string) => void' is not assignable to parameter of type '(this: void, item: string) => void'.
The 'this' types of each signature are incompatible.
Type 'void' is not assignable to type 'Logger'.logBound passes because an arrow-function field captures this when the object is constructed. That’s the usual fix for event handlers. It has costs worth mentioning: each instance gets its own function instead of sharing one on the prototype, and a subclass can’t call it through super.logBound(). Both checks only work if both sides opt in: without this: Logger on log, the call compiles. In practice, few codebases annotate every method, which is why noImplicitThis and arrow fields do most of the work.
Mutable chains leak; immutable chains copy
The second output line, true SELECT * FROM orders WHERE total > 100 LIMIT 5, shows the other problem. base.limit(5) changed base, because every method mutates and returns the same object. Code that builds a shared prefix and then branches (active.limit(10) for page one, active.limit(20) for an export) ends up with both limits on both queries. This is an API design bug rather than a type error, but it’s the follow-up question interviewers ask after the this discussion.
The fix is to return a modified copy. Copying while keeping the subclass type requires creating an object with the same prototype, since new Query(...) would lose the subclass:
class Query {
readonly table: string;
protected readonly parts: readonly string[] = [];
constructor(table: string) {
this.table = table;
}
protected with(part: string): this {
const next: this = Object.create(Object.getPrototypeOf(this));
Object.assign(next, this, { parts: [...this.parts, part] });
return next;
}
where(clause: string): this { return this.with(`WHERE ${clause}`); }
limit(n: number): this { return this.with(`LIMIT ${n}`); }
toSQL(this: Query): string {
return [`SELECT * FROM ${this.table}`, ...this.parts].join(' ');
}
}
class PgQuery extends Query {
returning(cols: string): this { return this.with(`RETURNING ${cols}`); }
}
const active = new PgQuery('users').where('active');
const page1 = active.limit(10);
const page2 = active.limit(20).returning('id');
console.log(active.toSQL());
console.log(page1.toSQL());
console.log(page2.toSQL());
console.log(page2 instanceof PgQuery);SELECT * FROM users WHERE active
SELECT * FROM users WHERE active LIMIT 10
SELECT * FROM users WHERE active LIMIT 20 RETURNING id
trueactive is untouched, each branch has only its own clauses, and the copies are still PgQuery instances. Object.create returns any, so the next: this annotation is the one place where you vouch for the type. Object.assign copies own enumerable fields, which is right for plain data like table and parts; a subclass holding a database connection or a #private field would need a dedicated clone method, because private fields aren’t copied by Object.assign.
this in type guards
One more form: a method can narrow the object it’s called on with a this is return type. That’s handy for objects that go through states:
class Upload {
url?: string;
isDone(): this is this & { url: string } {
return this.url !== undefined;
}
}
declare const avatar: Upload;
if (avatar.isDone()) console.log(avatar.url.length);
console.log(avatar.url.length);q6.ts(9,13): error TS18048: 'avatar.url' is possibly 'undefined'.Inside the if, url is a string; outside, it’s still optional. As with any type guard, the compiler trusts your implementation of isDone, so keep the check and the type next to each other.
Interview exercise: an HTTP request builder
You’re designing request('/users').header('Accept', 'application/json').timeout(5000).send(). A subclass AuthedRequest adds .bearer(token). Requirements: chains must keep subclass methods, a configured base request must be reusable for many calls, and passing req.send as a callback to a retry helper must not silently lose the request. How do you type it?
A reasoned answer
Every configuration method returns this, so .header(...).bearer(...) type-checks on an AuthedRequest. To make a configured request reusable, the methods return copies rather than mutating, using a protected with() helper that creates an object with Object.create(Object.getPrototypeOf(this)) and copies the fields. Headers are copied into a new object each time, because a shallow copy would share the headers map between branches and reintroduce the leak.
For the callback case I’d make send an arrow-function field, so retry(req.send) works because send already captured its request. If I wanted to keep it on the prototype instead, I’d declare send(this: Request) and type the retry helper’s parameter as (this: void) => Promise<Response>, so passing the bare method fails to compile and the caller writes retry(() => req.send()). The arrow field is friendlier; the this parameter is stricter and keeps one shared method. Either is defensible if you can explain the trade-off, which is the point of the question.