1. The Goal: A Fluent API#
We want to create a QueryBuilder that lets us chain .select() methods. Every time we select a field, the final result type should magically know about that field.
const query = new QueryBuilder()
.select("id", "string")
.select("age", "number");
// The result must be strictly inferred as: { id: string, age: number }
const result = query.execute();If we just used standard classes, query would just be of type QueryBuilder and execute() would have to return any or Record<string, any>. We need the class to remember the steps we took!
2. The Generic Accumulator#
The trick is that the QueryBuilder class must take a Generic parameter T representing its current state.
Every time a chained method is called, it returns a brand new instance of QueryBuilder, but with a mutated Generic parameter (T & NewType).
// 'T' holds the accumulated state. It defaults to an empty object.
class QueryBuilder<T = {}> {
// The generic 'K' is the property key name.
// The generic 'VType' determines the runtime data type.
select<K extends string, VType extends "string" | "number">(
key: K,
type: VType
): QueryBuilder<T & Record<K, VType extends "string" ? string : number>> {
// At runtime, we just return the same class instance to allow chaining.
// But in Type Space, we cast it to the NEW accumulated type!
return this as any;
}
// Execute returns the final accumulated state 'T'
execute(): T {
// Runtime execution logic...
return {} as T;
}
}3. Experiencing the Magic#
When we use our builder, the TypeScript compiler evaluates the generic math at every dot in the chain!
// 1. Initial State: QueryBuilder<{}>
const q1 = new QueryBuilder();
// 2. State: QueryBuilder<{} & Record<"id", string>>
const q2 = q1.select("id", "string");
// 3. State: QueryBuilder<{ id: string } & Record<"age", number>>
const q3 = q2.select("age", "number");
// 4. Final execution extracts the accumulated T!
const data = q3.execute();
/*
data is perfectly and strictly typed as:
{
id: string;
age: number;
}
*/
// 🟢 Valid Access
console.log(data.id);
console.log(data.age);
// 🔴 Compiler Error! Property 'name' does not exist.
// console.log(data.name);
Why is this revolutionary?#
This pattern of returning this as any as NewType allows you to construct massive, type-safe DSLs (Domain Specific Languages) entirely within TypeScript.
When you use z.object({ name: z.string() }) in Zod, or prisma.user.findMany({ select: { id: true } }), you are interacting with heavily engineered Generic Accumulators. They provide a developer experience that feels like pure magic, catching database or validation errors before you even save the file.
Summary & Next Steps#
In this episode:
- We identified the limitations of standard class methods for building dynamic types.
- We constructed a
QueryBuilderclass parameterized by an accumulatorT. - We used intersection (
&) to merge new properties into the accumulator at every step. - We returned the mutated class instance to enable fluent method chaining.
In Episode 55: Advanced Function Composition, our final episode, we will tackle the ultimate boss of TypeScript: writing the types for a variadic compose() function that stitches together an unknown number of generic functions!

