Builder Pattern type-safe

Bài 12: Builder Pattern type-safe

Bạn đã bao giờ dùng API kiểu builder.select("*").from("users").where("age", ">", 18).orderBy("name").build() và tự hỏi: TypeScript làm sao biết type chính xác sau mỗi .method() call? Đó là sức mạnh của Builder Pattern kết hợp Generics.


#Vấn đề: Mất type qua method chain

// ❌ Anti-pattern: mỗi method trả về cùng một type — mất thông tin
class QueryBuilder {
  private query: Record<string, unknown> = {};
 
  select(fields: string[]): this {
    this.query.select = fields;
    return this; // ✅ OK — 'this' giữ type
  }
 
  from(table: string): this {
    this.query.from = table;
    return this;
  }
 
  build(): Record<string, unknown> {
    // ❌ Trả về Record — mất cấu trúc cụ thể
    return this.query;
  }
}
 
const result = new QueryBuilder()
  .select(["name", "age"])
  .from("users")
  .build();
 
// result có type Record<string, unknown> — không biết cấu trúc
console.log(result.table); // Không báo lỗi dù không có "table"
// ✅ Builder pattern với generics — mỗi bước cập nhật type
type QueryState = {
  select?: string[];
  from?: string;
  where?: { field: string; op: string; value: unknown };
  orderBy?: string;
  limit?: number;
};
 
class QueryBuilder<T extends QueryState = {}> {
  private state: T;
 
  constructor(state: T = {} as T) {
    this.state = state;
  }
 
  select<S extends string>(...fields: S[]): QueryBuilder<T & { select: S[] }> {
    return new QueryBuilder({
      ...this.state,
      select: fields,
    });
  }
 
  from<F extends string>(table: F): QueryBuilder<T & { from: F }> {
    return new QueryBuilder({
      ...this.state,
      from: table,
    });
  }
 
  build(): T {
    return this.state;
  }
}

#Builder Pattern cơ bản — Fluent API với Generics

Ý tưởng cốt lõi: mỗi method trả về một type mới phản ánh trạng thái hiện tại.

// ✅ Config builder giữ type qua từng bước
type Config = {
  host?: string;
  port?: number;
  debug?: boolean;
  database?: string;
};
 
class ConfigBuilder<T extends Config = {}> {
  private config: T;
 
  constructor(config: T = {} as T) {
    this.config = config;
  }
 
  host<H extends string>(h: H): ConfigBuilder<T & { host: H }> {
    return new ConfigBuilder({ ...this.config, host: h });
  }
 
  port<P extends number>(p: P): ConfigBuilder<T & { port: P }> {
    return new ConfigBuilder({ ...this.config, port: p });
  }
 
  debug<D extends boolean>(d: D): ConfigBuilder<T & { debug: D }> {
    return new ConfigBuilder({ ...this.config, debug: d });
  }
 
  build(): T {
    return this.config;
  }
}
 
// ✅ TypeScript biết chính xác cấu trúc sau mỗi bước
const config = new ConfigBuilder()
  .host("localhost")  // ConfigBuilder<{ host: "localhost" }>
  .port(3000)         // ConfigBuilder<{ host: "localhost"; port: 3000 }>
  .debug(true)        // ConfigBuilder<{ host: "localhost"; port: 3000; debug: true }>
  .build();
 
// Type của config: { host: "localhost"; port: 3000; debug: true }
console.log(config.host);  // ✅ "localhost"
console.log(config.port);  // ✅ 3000
// console.log(config.database); // ❌ Error: Property 'database' does not exist

#Builder với Validation — Bắt lỗi tại compile time

// ✅ Yêu cầu: phải gọi select() và from() trước khi build()
type QueryRequired = {
  select: boolean;
  from: boolean;
};
 
class SafeQueryBuilder<T extends QueryRequired = { select: false; from: false }> {
  private state: Record<string, unknown> = {};
 
  select(...fields: string[]): SafeQueryBuilder<T & { select: true }> {
    this.state.select = fields;
    return this as unknown as SafeQueryBuilder<T & { select: true }>;
  }
 
  from(table: string): SafeQueryBuilder<T & { from: true }> {
    this.state.from = table;
    return this as unknown as SafeQueryBuilder<T & { from: true }>;
  }
 
  // build chỉ available khi cả select và from đều đã gọi
  build(this: SafeQueryBuilder<{ select: true; from: true }>): Record<string, unknown> {
    return { ...this.state };
  }
}
 
// ❌ Không gọi select → không build được
new SafeQueryBuilder().from("users").build();
// Error: Property 'build' does not exist on type 'SafeQueryBuilder<{ select: false; from: true }>'
 
// ✅ Phải gọi cả hai
new SafeQueryBuilder().select("*").from("users").build(); // OK

#Query Builder thực tế

// ✅ Full query builder với type safety
type ComparisonOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "like";
 
interface Condition {
  field: string;
  op: ComparisonOp;
  value: unknown;
}
 
type QueryShape = {
  select?: string[];
  from?: string;
  where?: Condition[];
  orderBy?: { field: string; direction: "asc" | "desc" };
  limit?: number;
};
 
class QueryBuilder<T extends QueryShape = {}> {
  private state: T;
 
  constructor(state = {} as T) {
    this.state = state;
  }
 
  select<S extends string>(...fields: S[]): QueryBuilder<T & { select: S[] }> {
    return new QueryBuilder({ ...this.state, select: fields } as T & { select: S[] });
  }
 
  from<F extends string>(table: F): QueryBuilder<T & { from: F }> {
    return new QueryBuilder({ ...this.state, from: table } as T & { from: F });
  }
 
  where(field: string, op: ComparisonOp, value: unknown): QueryBuilder<T & { where: Condition[] }> {
    const existing = (this.state.where as Condition[]) || [];
    return new QueryBuilder({
      ...this.state,
      where: [...existing, { field, op, value }],
    } as T & { where: Condition[] });
  }
 
  orderBy(field: string, direction: "asc" | "desc" = "asc"): QueryBuilder<T & { orderBy: { field: string; direction: "asc" | "desc" } }> {
    return new QueryBuilder({
      ...this.state,
      orderBy: { field, direction },
    } as T & { orderBy: { field: string; direction: "asc" | "desc" } });
  }
 
  limit<N extends number>(n: N): QueryBuilder<T & { limit: N }> {
    return new QueryBuilder({ ...this.state, limit: n } as T & { limit: N });
  }
 
  build(): T {
    return this.state;
  }
}
 
// ✅ Sử dụng
const query = new QueryBuilder()
  .select("name", "age", "email")
  .from("users")
  .where("age", "gte", 18)
  .where("status", "eq", "active")
  .orderBy("name", "asc")
  .limit(10)
  .build();
 
// TypeScript biết cấu trúc đầy đủ
console.log(query.select);  // ✅ string[]
console.log(query.from);    // ✅ string
console.log(query.limit);   // ✅ number

#Anti-pattern: Mất type trong chain

// ❌ Anti-pattern: dùng any hoặc interface rộng
interface IQueryBuilder {
  select(fields: string[]): IQueryBuilder;
  from(table: string): IQueryBuilder;
  where(field: string, op: string, value: unknown): IQueryBuilder;
  build(): Record<string, unknown>;
}
 
// Mọi method trả về cùng type → không biết state hiện tại
const q: IQueryBuilder = builder.select("*").from("users").build();
// q là Record<string, unknown> — mất hết thông tin

Giải pháp: Mỗi method phải trả về type mới (generic) chứ không trả về cùng interface.


#Path Builder — Ứng dụng thực tế

// ✅ Type-safe route builder
type RouteSegments = string[];
 
class RouteBuilder<T extends RouteSegments = []> {
  private segments: T;
 
  constructor(segments = [] as unknown as T) {
    this.segments = segments;
  }
 
  path<S extends string>(segment: S): RouteBuilder<[...T, S]> {
    return new RouteBuilder([...this.segments, segment] as unknown as [...T, S]);
  }
 
  param<S extends string>(name: S): RouteBuilder<[...T, `:${S}`]> {
    return new RouteBuilder([...this.segments, `:${name}`] as unknown as [...T, `:${S}`]);
  }
 
  build(): `/${string}` {
    return `/${this.segments.join("/")}` as `/${string}`;
  }
}
 
// ✅ Type-safe routes
const userRoute = new RouteBuilder()
  .path("api")
  .path("v1")
  .path("users")
  .param("id")
  .build();
 
// type: "/api/v1/users/:id"
console.log(userRoute); // "/api/v1/users/:id"

#Best Practices

  1. Mỗi method trả về type mới — không dùng this khi muốn cập nhật type
  2. Dùng intersection type (T & { newProp: type }) để tích lũy state
  3. Literal types cho constantsS extends string thay vì chỉ string
  4. Conditional build — dùng this overload để bắt buộc gọi certain methods trước
  5. Không quá phức tạp — nếu chain dài hơn 6-7 methods, cân nhắc tách thành nhiều builders

#Bài tập

#Dễ

Viết StringBuilder với methods append(str), prepend(str), build() trả về string. Dùng generics để giữ literal type của chuỗi.

#Trung bình

Viết UrlBuilder với methods protocol(p), host(h), port(p), path(p), build() trả về URL string với type chính xác.

#Khó

Viết TypeSafeQueryBuilder mà bắt buộc phải gọi select()from() trước khi gọi build(). Nếu thiếu, TypeScript phải báo lỗi compile-time.


#Tổng kết

  • Builder Pattern với Generics giữ type qua từng method chain
  • Mỗi method trả về type mới phản ánh trạng thái tích lũy
  • Intersection type T & { prop: type } là key để tích lũy state
  • Có thể bắt buộc gọi certain methods trước build() bằng conditional types
  • Không dùng this return type khi muốn thay đổi generic type
  • Luôn dùng literal types để TypeScript biết giá trị cụ thể

Bài tiếp theo: Utility Types toàn tập — reimplement tất cả utility types tích hợp sẵn của TypeScript.