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 tinGiả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
- Mỗi method trả về type mới — không dùng
thiskhi muốn cập nhật type - Dùng intersection type (
T & { newProp: type }) để tích lũy state - Literal types cho constants —
S extends stringthay vì chỉstring - Conditional build — dùng
thisoverload để bắt buộc gọi certain methods trước - 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() và 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
thisreturn 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.