Design Patterns in TypeScript · Part 3 — Builder & Fluent APIs
Construct complex objects step by step: fluent method chaining, immutable builders, the type-safe staged (phantom-type) builder that makes illegal states unrepresentable, and when an options object is enough.
Phần 3/10 trong series Design Patterns in TypeScript. Trước: Tiếp:
Trong Phần 2 bạn đã gom tạo object nào. Phần này xử lý cách lắp một object khi constructor trở nên khó đọc: mười hai tham số vị trí, bốn boolean tùy chọn, và một null chỉ lộ ra lúc chạy.
Telescoping constructor (overload chồng overload) và new Thing(a, b, c, …) khổng lồ là thuế bảo trì. Builder cho phép gọi từng bước, rồi .build() một lần khi product đủ. Trong TypeScript bạn có thể đi xa hơn: chuỗi fluent, bước bất biến, và kiểu theo giai đoạn từ chối .build() cho tới khi đủ field bắt buộc.
Ý đồ
Builder tách dựng khỏi biểu diễn: một director (hoặc caller) chạy chuỗi bước; builder tích lũy state; .build() trả product cuối. Bạn dùng khi object có nhiều phần tùy chọn, validate giữa các bước, hoặc nhiều “công thức” hợp lệ.
Pattern không phải “luôn hơn một object thường”. Ta sẽ nói thẳng khi nào object config là đủ.
Builder fluent — chuỗi method trả về this
API fluent kinh điển: mỗi setter trả this để gọi nối tiếp:
interface HttpRequest {
readonly url: string;
readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
}
class RequestBuilder {
private url = '';
private method: HttpRequest['method'] = 'GET';
private headers: Record<string, string> = {};
private body: string | undefined;
forUrl(url: string): this {
this.url = url;
return this;
}
withMethod(method: HttpRequest['method']): this {
this.method = method;
return this;
}
header(name: string, value: string): this {
this.headers[name] = value;
return this;
}
jsonBody(payload: unknown): this {
this.headers['content-type'] = 'application/json';
this.body = JSON.stringify(payload);
return this;
}
build(): HttpRequest {
if (!this.url) {
throw new Error('url is required');
}
return {
url: this.url,
method: this.method,
headers: { ...this.headers },
body: this.body,
};
}
}
const req = new RequestBuilder()
.forUrl('/api/users')
.withMethod('POST')
.header('authorization', 'Bearer …')
.jsonBody({ name: 'An' })
.build();
Vì sao hợp trên web: query builder, wrapper fetch, client SDK đọc như tiếng Anh và giấu default. đánh đổi: một instance builder có thể ghi — tái sử dụng cùng object giữa các lần build có thể rò state.
Builder bất biến — mỗi bước trả builder MỚI
Để nhánh công thức và tái dùng không bất ngờ, trả builder mới thay vì ghi this:
interface Query {
readonly table: string;
readonly select: readonly string[];
readonly where: readonly { col: string; op: '=' | '>'; val: string | number }[];
readonly limit?: number;
}
class QueryBuilder {
private constructor(private readonly state: Query) {}
static from(table: string): QueryBuilder {
return new QueryBuilder({ table, select: ['*'], where: [] });
}
select(...columns: string[]): QueryBuilder {
return new QueryBuilder({ ...this.state, select: columns });
}
where(col: string, op: '=' | '>', val: string | number): QueryBuilder {
return new QueryBuilder({
...this.state,
where: [...this.state.where, { col, op, val }],
});
}
take(n: number): QueryBuilder {
return new QueryBuilder({ ...this.state, limit: n });
}
build(): Query {
return this.state;
}
}
const base = QueryBuilder.from('orders').select('id', 'total');
const paid = base.where('status', '=', 'paid').take(50);
const pending = base.where('status', '=', 'pending').take(10);
// `base` is unchanged — safe branching
Mỗi bước là chuyển trạng thái thuần trên dữ liệu bạn đã có. Chi phí: thêm allocation (thường không đáng kể); lợi: không chia sẻ builder mutable khắp codebase.
Builder theo giai đoạn an toàn kiểu — trạng thái sai không biểu diễn được
Kiểm tra runtime trong .build() (if (!url) throw …) bắt lỗi muộn. Builder theo giai đoạn luồn field nào đã set qua generic; .build() chỉ có kiểu khi key bắt buộc đã có.
Hãy coi đó là checklist được mã hoá trong hệ kiểu:
type Steps = {
to: string;
subject: string;
body: string;
};
/** Present = union of step names already called */
class EmailBuilder<Present extends keyof Steps = never> {
private constructor(private readonly draft: Partial<Steps>) {}
static create(): EmailBuilder {
return new EmailBuilder({});
}
to(address: string): EmailBuilder<Present | 'to'> {
return new EmailBuilder({ ...this.draft, to: address });
}
subject(line: string): EmailBuilder<Present | 'subject'> {
return new EmailBuilder({ ...this.draft, subject: line });
}
body(text: string): EmailBuilder<Present | 'body'> {
return new EmailBuilder({ ...this.draft, body: text });
}
// Only callable when all required keys are in Present
build(this: EmailBuilder<keyof Steps>): { readonly to: string; readonly subject: string; readonly body: string } {
const { to, subject, body } = this.draft;
// Narrowed by the `this` type — still validate for defense in depth
if (to === undefined || subject === undefined || body === undefined) {
throw new Error('incomplete email');
}
return { to, subject, body };
}
}
const ok = EmailBuilder.create()
.to('dev@example.com')
.subject('Deploy done')
.body('v1.2.0 is live')
.build();
// Uncomment to see a COMPILE error — build() is not on the type until all steps ran:
// const bad = EmailBuilder.create().to('x@y.z').build();
Cách kiểu luồn: mỗi method fluent trả EmailBuilder<Present | 'to'> (v.v.), mở rộng tập bước đã thỏa. tham số this của build yêu cầu Present là mọi key của Steps. Bỏ .subject() thì TypeScript không cho .build() trên kết quả. Đó là lợi ích senior: pipeline sai fail trong editor, không phải production.
Có thể kết hợp staging với trả bất biến (như trên) hoặc builder mutable + brand ảo — khuyên bất biến + typing this.
Builder vs object tùy chọn — khi {} đơn giản hơn
Không phải config nào cũng cần builder.
- Ít field (≤ ~5) và không có quy tắc thứ tự giữa chúng.
- Mọi field tùy chọn hoặc default rõ ràng.
- Không cần công thức khác nhau — một shape cho mọi nơi gọi.
interface CreateChartOptions {
width?: number;
height?: number;
theme?: 'dark' | 'light';
}
function createChart(data: number[], options: CreateChartOptions = {}) {
const width = options.width ?? 640;
const height = options.height ?? 400;
const theme = options.theme ?? 'dark';
return { data, width, height, theme };
}
createChart([1, 2, 3], { width: 800 });
Dùng builder khi: call site cần nhiều overload, validate có thứ tự, factory dữ liệu test với biến thể dễ đọc, hoặc ép buộc compile-time các bước bắt buộc. Thành thật: builder cho ba cờ tùy chọn là nghi thức thừa.
Use case web thực tế
- Query / builder kiểu ORM —
select().from().where().limit()cho SQL hoặc API document. - Builder dữ liệu test —
user().withRole('admin').verified().build()bên cạnh factory Phần 2. - Payload notification / email / webhook — nhiều header, attachment, chính sách retry tùy chọn.
- Config chart và dashboard — trục, series, legend, cờ tương tác lắp dần.
- HTTP client — base URL, auth, timeout, rồi path và body từng request.
Cạm bẫy
.build()khi state chưa hợp lệ — chỉthrowruntime, không staging.- Tái dùng một builder mutable — lần
.build()thứ hai kế thừa header/body lần đầu. - Over-engineering — builder một method, hoặc bọc DTO đã phẳng.
- Lộ builder — trả builder thay vì product; caller tiếp tục ghi.
- Interface fluent khổng lồ — hàng chục method trên một class; tách bằng hàm director hoặc staged type nhỏ hơn.
Bảng tra nhanh
// Fluent (mutable) — ergonomic, watch reuse
class B {
private x = '';
setX(v: string): this { this.x = v; return this; }
build() { return { x: this.x }; }
}
// Immutable — each step returns new B
class Imm {
private constructor(private readonly s: { x: string }) {}
static empty() { return new Imm({ x: '' }); }
setX(v: string) { return new Imm({ x: v }); }
build() { return this.s; }
}
// Staged — Present tracks required steps; build(this: …) gates compile
class Staged<Present extends 'a' | 'b' = never> {
private constructor(private readonly d: Partial<Record<'a' | 'b', string>>) {}
static create() { return new Staged({}); }
a(v: string): Staged<Present | 'a'> { return new Staged({ ...this.d, a: v }); }
b(v: string): Staged<Present | 'b'> { return new Staged({ ...this.d, b: v }); }
build(this: Staged<'a' | 'b'>) {
const { a, b } = this.d;
if (a === undefined || b === undefined) throw new Error('incomplete');
return { a, b };
}
}
// Often enough: options object + defaults
function go(opts: { url?: string } = {}) { /* … */ }
Quyết định: ít field tùy chọn → options; biến thể dễ đọc → builder bất biến; bước bắt buộc phải ép → staged this.
Bài tập / Exercises
1. Cài RequestBuilder fluent có .url(), .method(), .header() tùy chọn, và .build() throw nếu thiếu url.
Lời giải
class RequestBuilder {
private url = '';
private method: 'GET' | 'POST' = 'GET';
private headers: Record<string, string> = {};
urlPath(path: string): this {
this.url = path;
return this;
}
method(m: 'GET' | 'POST'): this {
this.method = m;
return this;
}
header(k: string, v: string): this {
this.headers[k] = v;
return this;
}
build() {
if (!this.url) throw new Error('url required');
return { url: this.url, method: this.method, headers: { ...this.headers } };
}
}
const r = new RequestBuilder().urlPath('/health').method('GET').build();2. Viết lại bài 1 dạng bất biến: mỗi method trả instance mới; chứng minh hai nhánh từ cùng base không dùng chung state.
Lời giải
interface ReqState {
url: string;
method: 'GET' | 'POST';
headers: Record<string, string>;
}
class ImmRequestBuilder {
private constructor(private readonly state: ReqState) {}
static start(): ImmRequestBuilder {
return new ImmRequestBuilder({ url: '', method: 'GET', headers: {} });
}
urlPath(path: string): ImmRequestBuilder {
return new ImmRequestBuilder({ ...this.state, url: path });
}
method(m: 'GET' | 'POST'): ImmRequestBuilder {
return new ImmRequestBuilder({ ...this.state, method: m });
}
build() {
if (!this.state.url) throw new Error('url required');
return { ...this.state, headers: { ...this.state.headers } };
}
}
const root = ImmRequestBuilder.start();
const get = root.urlPath('/a').method('GET');
const post = root.urlPath('/b').method('POST');
console.log(get.build().method, post.build().method); // GET POST — independent3. Cài staged SignupBuilder — phải gọi .email(), .password(), .displayName() trước khi .build() có trên kiểu.
Lời giải
type SignupSteps = { email: string; password: string; displayName: string };
class SignupBuilder<Present extends keyof SignupSteps = never> {
private constructor(private readonly data: Partial<SignupSteps>) {}
static create(): SignupBuilder {
return new SignupBuilder({});
}
email(v: string): SignupBuilder<Present | 'email'> {
return new SignupBuilder({ ...this.data, email: v });
}
password(v: string): SignupBuilder<Present | 'password'> {
return new SignupBuilder({ ...this.data, password: v });
}
displayName(v: string): SignupBuilder<Present | 'displayName'> {
return new SignupBuilder({ ...this.data, displayName: v });
}
build(this: SignupBuilder<keyof SignupSteps>) {
const { email, password, displayName } = this.data;
if (email === undefined || password === undefined || displayName === undefined) {
throw new Error('incomplete signup');
}
return { email, password, displayName };
}
}
// const fail = SignupBuilder.create().email('a@b.c').build(); // TS error
const user = SignupBuilder.create()
.email('a@b.c')
.password('secret')
.displayName('An')
.build();4. Cho interface ServerOpts { host?: string; port?: number; tls?: boolean }, cài createServer(opts) với default — không builder. Nêu một tình huống vẫn nên dùng builder.
Lời giải
interface ServerOpts {
host?: string;
port?: number;
tls?: boolean;
}
function createServer(opts: ServerOpts = {}) {
return {
host: opts.host ?? '127.0.0.1',
port: opts.port ?? 3000,
tls: opts.tls ?? false,
};
}
createServer({ port: 8080, tls: true });Dùng builder khi cần setup có thứ tự (credential TLS trước listen) hoặc field bắt buộc compile-time cho nhánh production.
Nâng cao:thêm .attach() tùy chọn vào SignupBuilder không chặn .build() — chỉ email, password, displayName bắt buộc. Gợi ý: tách key bắt buộc trong SignupSteps khỏi key tùy chọn bằng generic hoặc method khác.
Lời giải
type RequiredSignup = { email: string; password: string; displayName: string };
type OptionalSignup = { avatarUrl?: string };
class SignupBuilder2<Present extends keyof RequiredSignup = never> {
private constructor(
private readonly required: Partial<RequiredSignup>,
private readonly optional: OptionalSignup,
) {}
static create(): SignupBuilder2 {
return new SignupBuilder2({}, {});
}
email(v: string): SignupBuilder2<Present | 'email'> {
return new SignupBuilder2({ ...this.required, email: v }, this.optional);
}
password(v: string): SignupBuilder2<Present | 'password'> {
return new SignupBuilder2({ ...this.required, password: v }, this.optional);
}
displayName(v: string): SignupBuilder2<Present | 'displayName'> {
return new SignupBuilder2({ ...this.required, displayName: v }, this.optional);
}
attachAvatar(url: string): SignupBuilder2<Present> {
return new SignupBuilder2(this.required, { ...this.optional, avatarUrl: url });
}
build(this: SignupBuilder2<keyof RequiredSignup>) {
const { email, password, displayName } = this.required;
if (email === undefined || password === undefined || displayName === undefined) {
throw new Error('incomplete signup');
}
return { email, password, displayName, ...this.optional };
}
}Điểm chính
- Builder = dựng từng bước, rồi một product
.build(). - Fluent + mutable tiện; bước bất biến an toàn hơn khi nhánh và tái dùng.
- Generic staged +
thistrênbuild()biến thiếu field bắt buộc thành lỗi biên dịch. - Object tùy chọn thường thắng builder cho config nhỏ, phẳng — đừng bọc nghi thức quanh DTO đơn giản.
Tiếp theo
Phần 4 — Strategy: đổi thuật toán lúc runtime không rải switch — map strategy có kiểu, injection, và khi không nên abstract.