TypeScript design patterns · 5 / 8
lesson 5

Builder patterns and method chaining

A fluent API that refuses to compile until you've called the steps that matter.

~ 17 min read·lesson 5 of 8
0 / 8

A new teammate calls your query helper for the first time: db.query().limit(10).exec(). It runs. It returns nothing. They wonder why. They check the docs — turns out you have to call .from("users") first, otherwise the executor doesn't know which table to ask. The docs said this. The compiler said nothing. This lesson is about closing that gap so the wrong call simply doesn't compile.

Returning this

A fluent API is a chain of method calls where each method returns something you can call the next method on. Builders are the most common case: you call methods to set parts of an object, then a final method to produce it.

builder.ts
class QueryBuilder {
private table = "";
private cap = 100;

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

limit(n: number): this {
  this.cap = n;
  return this;
}

exec(): string {
  return "SELECT * FROM " + this.table + " LIMIT " + this.cap;
}
}

const sql = new QueryBuilder().from("users").limit(10).exec();

The trick is the return type this. Each setter mutates the builder and returns the same instance. The chain works because from(...) hands back a QueryBuilder, and a QueryBuilder has .limit, and so on.

this as a return type is more useful than writing QueryBuilder because this follows subclasses. If a JoinedQueryBuilder extends QueryBuilder and adds .join(...), then joinedBuilder.from("users").join(...) still works — this resolves to the subclass at each step. Writing QueryBuilder instead of this would have lost that.

check your understanding
Why is this a better return type than the class name in a fluent setter?

Required steps

The previous builder is friendly to write and silently broken to use. new QueryBuilder().limit(10).exec() compiles cleanly — from(...) is optional from the type system's perspective, even though the resulting query is wrong.

The fix is to track, in the type, whether the required step has been taken. The builder starts in a state where exec doesn't exist. Calling from(...) returns a different (narrower) type — one where exec does exist. Now the wrong order is a compile error, not a runtime surprise.

builder.ts
type State = { hasFrom: boolean };

class QueryBuilder<S extends State> {
private table = "";
private cap = 100;

from(name: string): QueryBuilder<{ hasFrom: true }> {
  this.table = name;
  return this as unknown as QueryBuilder<{ hasFrom: true }>;
}

limit(n: number): this {
  this.cap = n;
  return this;
}

exec(this: QueryBuilder<{ hasFrom: true }>): string {
  return "SELECT * FROM " + this.table + " LIMIT " + this.cap;
}
}

Two pieces are doing the work. First, QueryBuilder<S> is parameterised by a small type S describing what's been called. The starting builder is QueryBuilder<{ hasFrom: false }>. After from(...), the return type widens nothing on the runtime side but tightens the type to QueryBuilder<{ hasFrom: true }>.

Second, exec declares a this parameter — TypeScript lets a method specify the type of this separately from the type of the class. exec's this requires hasFrom: true. Calling exec on a builder where hasFrom is still false is rejected by the compiler.

usage.ts
const start = new QueryBuilder<{ hasFrom: false }>();

start.exec();                          /* error: exec needs hasFrom: true */
start.limit(10).exec();                /* error: limit returns this; still false */
start.from("users").exec();            /* compiles */
start.from("users").limit(10).exec();  /* compiles */

The third call compiles because from("users") returns QueryBuilder<{ hasFrom: true }>. The first two are caught at build time. The error message points at the call site of exec — exactly where the missing step matters.

Phantom flags in the type

State only exists in the types. There is no runtime hasFrom field. The flag rides on the type parameter, not on the instance. That's why this trick is sometimes called a phantom type — a type-level marker that affects which methods you can call but never appears at runtime.

hasFrom falsehasFrom trueexec().from(...) runtime: same instance type: now allowed
Each setter call produces a builder type with one more flag flipped. exec only matches the type with all required flags set.

The pattern scales to multiple required steps. Add hasUrl, hasMethod, hasBody, and require certain combinations on send(). It also scales the other way: optional steps don't need a flag at all. They just return this and don't change S.

request.ts
type Req = { hasUrl: boolean; hasMethod: boolean };

class RequestBuilder<S extends Req> {
url(u: string): RequestBuilder<S & { hasUrl: true }> { return this as any; }
method(m: string): RequestBuilder<S & { hasMethod: true }> { return this as any; }
header(k: string, v: string): this { return this; }
send(this: RequestBuilder<{ hasUrl: true; hasMethod: true }>): Promise<Response> {
  return fetch("...");
}
}

url(...) flips hasUrl, method(...) flips hasMethod, and header(...) is optional — it returns this and doesn't touch the flags. send requires both flags. Order doesn't matter; what matters is that both have been set by the time send is called. header(...) can be called zero times, once, or many times.

Tip

Use intersection (S & { hasUrl: true }) to add a flag without losing the others the chain has already accumulated. Replacing S wholesale forgets prior steps.

check your understanding
You write builder.method("GET").send() on the request builder above. What does TypeScript say?

When the pattern fits

This pattern earns its complexity when the API is public and the cost of a misuse is real — a query that runs against the wrong table, a request that goes out without a URL, a config that compiles in development and explodes at startup. The compiler is doing the work of a documentation page that nobody reads.

It's overkill for one-off internal helpers. If you're the only caller and the chain has two methods, a regular builder with a runtime check (or just a function with arguments) is friendlier. The phantom-flag pattern adds noise to the source — generic parameters, as any casts in the implementation, type-level bookkeeping. That noise pays off in libraries and shared APIs; it gets in the way for a small team's internal code.

A useful rule: ask if the chain has required steps that are easy to forget. If yes, the type-level guard pays off. If every step is optional, return this and stop there. Don't add machinery that doesn't catch a bug.

alternative.ts
/* When you have all the inputs at once, a regular function beats a builder. */
function buildQuery(opts: { from: string; limit?: number }): string {
return "SELECT * FROM " + opts.from + " LIMIT " + (opts.limit ?? 100);
}

const sql = buildQuery({ from: "users", limit: 10 });

This compiles with the same safety — from is required, limit is optional — and reads more directly. Builders pay off when calls are spread across files, when you want a partial config to flow through several places, or when subclasses need to extend the chain. They lose to a plain object literal when neither of those applies.

Watch out

The phantom-flag builder uses casts (as unknown as ..., as any) inside its setters. Those casts are the cost of the trick. Hide them inside the class. Never let consumers see or replicate them — the safety is the type signature, not the body.

check your understanding
Which of these is the strongest sign you should reach for the phantom-flag builder over a function that takes an options object?
check your understanding
You add a new required step to the builder, where(...). Which signature gets you a compile error on every existing call site that forgot to add it?
check your understanding
Why does the implementation of from use this as unknown as QueryBuilder<{ hasFrom: true }>?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript design patterns
0 of 8 read