A fluent builder pattern typed with generics
Published June 8, 2027
export class QueryBuilder<Shape extends Record<string, unknown> = Record<string, never>> {
private constructor(private readonly clauses: Shape) {}
static create(): QueryBuilder<Record<string, never>> {
return new QueryBuilder({});
}
where<K extends string, V>(key: K, value: V): QueryBuilder<Shape & Record<K, V>> {
return new QueryBuilder({ ...this.clauses, [key]: value } as Shape & Record<K, V>);
}
build = (): Shape => this.clauses;
}
export const query = QueryBuilder.create().where("status", "active").where("limit", 10).build();What
QueryBuilder<Shape>'s where method doesn't just return this the way a typical fluent builder does — it returns a QueryBuilder<Shape & Record<K, V>>, a new type parameter that's the old shape intersected with the key/value just added. build() then returns exactly that accumulated shape, fully typed, with no cast.
Why it matters
The common fluent builder returns this from every chained method, which keeps the chain compiling but means build()'s return type has to be decided up front and stays fixed no matter what was actually built — usually some generic Record<string, unknown> or a manually-maintained interface. That throws away the most useful part of a builder: by the time you call build(), the compiler already knows, precisely, which keys you added and what you passed for each one, purely by tracking the chain of calls. Making where return a new generic instantiation instead of this means the type parameter itself accumulates the shape as you chain, and build()'s return type falls out of that automatically.
How it works
QueryBuilder's constructor isprivate— the only way to get an instance is throughstatic create(), which starts the chain atQueryBuilder<Record<string, never>>, an empty shape.- Each
where<K, V>(key: K, value: V)call infersKandVdirectly from its arguments — both are plain scalar generic parameters, so TypeScript already preserves their literal types without needing aconstmodifier — and returnsQueryBuilder<Shape & Record<K, V>>: a new instance whose type parameter is the running shape intersected with the newest key/value pair. build's type is justShape— whatever the accumulated intersection has become by the time it's called, with no additional logic needed.- Chaining
.where("status", "active").where("limit", 10)on a builder started fromcreate()ends withShapeequal toRecord<string, never> & Record<"status", "active"> & Record<"limit", 10>— practically, an object with astatus: "active"field and alimit: 10field.
Gotchas
- Each
.where()call constructs a brand-newQueryBuilderinstance rather than mutating one in place — that's necessary for the type to actually change between calls (a single mutable instance can't have two different types at two points in its life), but it means the builder allocates on every chained call, which matters if you're building in a hot loop rather than once per request. - Calling
.where("status", "active")twice with the same key doesn't overwrite the earlier value in the type —Record<"status", "active"> & Record<"status", "different">intersects to astatusfield typed"active" & "different", which TypeScript resolves tonever. The runtime object, via the object spread, does overwrite it, so the type and the value can disagree if a key is set more than once.
Related
- Method chaining that returns
this— simpler, and sufficient wheneverbuild()'s shape doesn't need to depend on which methods were actually called. consttype parameters — solve a related but distinct widening problem: preserving literal values passed into a generic, rather than accumulating a shape across a chain of calls.