10LOC
TypeScriptadvanced

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 is private — the only way to get an instance is through static create(), which starts the chain at QueryBuilder<Record<string, never>>, an empty shape.
  • Each where<K, V>(key: K, value: V) call infers K and V directly from its arguments — both are plain scalar generic parameters, so TypeScript already preserves their literal types without needing a const modifier — and returns QueryBuilder<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 just Shape — 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 from create() ends with Shape equal to Record<string, never> & Record<"status", "active"> & Record<"limit", 10> — practically, an object with a status: "active" field and a limit: 10 field.

Gotchas

  • Each .where() call constructs a brand-new QueryBuilder instance 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 typeRecord<"status", "active"> & Record<"status", "different"> intersects to a status field typed "active" & "different", which TypeScript resolves to never. 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 whenever build()'s shape doesn't need to depend on which methods were actually called.
  • const type 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.