Skip to main content

TS Ep 54: The Builder Pattern with Generics

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 54: This Article
How do modern libraries like tRPC or Prisma know the exact type of your database query before you even finish building it? They use a technique called Generic Accumulation within the Builder Pattern.

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 QueryBuilder class parameterized by an accumulator T.
  • 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!

typescript - This article is part of a series.
Part 54: This Article