Design Patterns in TypeScript · Part 2 — Factory & Abstract Factory
Centralize object creation: factory functions over `new`, discriminated-union driven factories, the Abstract Factory for families of related objects, and where this beats classes in TypeScript.
This is Part 2 of a 10-part series on the design patterns every senior web engineer should have in their hands — explained with runnable TypeScript, real frontend/back-of-front use cases, and exercises at the end of each part {Đây là Phần 2 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần}. In Part 1 — Singleton & the Module Pattern we centralized one shared instance {Ở Phần 1 — Singleton & Module Pattern ta đã gom một instance dùng chung}; here we centralize how new objects are born {ở đây ta gom cách sinh object mới}.
Part 2 of 10 — Design Patterns in TypeScript {Phần 2/10 — Design Patterns in TypeScript}. Previous {Trước}: Part 1 — Singleton & the Module Pattern · Next {Tiếp}: Part 3 — Builder & Fluent APIs. Every part ends with exercises; do them, don’t just read {Mỗi phần kết thúc bằng bài tập; hãy làm, đừng chỉ đọc}.
If your UI, API layer, or test setup is littered with new ConcreteThing() and switch (type) { case 'a': return new A() ... }, every caller is coupled to constructors {Nếu UI, API layer hay test setup của bạn đầy new ConcreteThing() và switch (type) { case 'a': return new A() ... }, mọi caller đang dính chặt constructor}. Change the implementation, add a variant, or swap environments — and you edit code in ten places {Đổi implementation, thêm biến thể, hay đổi môi trường — bạn sửa code ở mười chỗ}. Factories move that decision behind one function (or one registry) so the rest of the app depends on interfaces and data, not class names {Factory đưa quyết định đó sau một function (hoặc một registry) để phần còn lại của app phụ thuộc interface và data, không phải tên class}.
The intent {Ý đồ}
A Factory encapsulates object creation: callers say what they need (often as data), and the factory returns something that satisfies a contract {Factory đóng gói việc tạo object: caller nói cần gì (thường bằng data), factory trả về thứ thỏa một hợp đồng}. The Abstract Factory goes one level up: it creates a family of related products that must stay consistent — same theme, same platform, same wire format {Abstract Factory lên một bậc: tạo một họ product liên quan phải nhất quán — cùng theme, cùng platform, cùng wire format}.
You reach for factories when construction is non-trivial (config, env, parsing) or when you want compile-time exhaustiveness over a closed set of variants {Bạn dùng factory khi khởi tạo không tầm thường (config, env, parse) hoặc khi muốn exhaustiveness compile-time trên tập biến thể đóng}. You do not need a factory for every new — we’ll call out the needless ones {Bạn không cần factory cho mọi new — ta sẽ chỉ ra những cái thừa}.
Factory functions vs new {Factory function vs new}
The most idiomatic TypeScript factory is often a plain function that returns an interface, not a subclass tree {Factory TypeScript idiomatic nhất thường là function thường trả về interface, không phải cây subclass}:
interface HttpClient {
get(path: string): Promise<unknown>;
}
function createHttpClient(baseUrl: string): HttpClient {
return {
async get(path) {
const res = await fetch(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`${res.status} ${path}`);
return res.json();
},
};
}
// Composition root picks the base URL once; modules depend on HttpClient.
const api = createHttpClient(process.env.API_URL ?? 'http://localhost:3000');
Why this beats new FetchClient() everywhere {Vì sao thắng new FetchClient() khắp nơi}:
- Construction is one place — base URL, auth headers, retry policy live inside
createHttpClient{Khởi tạo một chỗ — base URL, header auth, retry policy nằm trongcreateHttpClient}. - Easy to swap — tests pass a fake
HttpClient; staging uses a mock server {Dễ thay — test truyềnHttpClientgiả; staging dùng mock server}. - No class ceremony — TypeScript’s type system cares about shape, not
extends{Không nghi thức class — type system TS quan tâm shape, không phảiextends}.
Use a class inside the factory only when you need instance methods with private state or a real prototype chain {Chỉ dùng class bên trong factory khi cần method instance với state private hoặc prototype chain thật}. The public surface stays the interface {Mặt trước vẫn là interface}.
Discriminated-union factories {Factory discriminated union}
When variants are a closed set, model input as a discriminated union and let switch narrow types {Khi biến thể là tập đóng, model input bằng discriminated union và để switch thu hẹp kiểu}. TypeScript will infer the return type per branch if each branch returns a compatible interface {TypeScript suy kiểu trả về theo nhánh nếu mỗi nhánh trả interface tương thích}:
type ShapeInput =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number };
interface Shape {
readonly kind: ShapeInput['kind'];
area(): number;
}
function createShape(input: ShapeInput): Shape {
switch (input.kind) {
case 'circle':
return {
kind: 'circle',
area: () => Math.PI * input.radius ** 2,
};
case 'rect':
return {
kind: 'rect',
area: () => input.width * input.height,
};
default: {
// If you add a variant and forget a case, this line errors.
const _exhaustive: never = input;
return _exhaustive;
}
}
}
const c = createShape({ kind: 'circle', radius: 3 });
c.area(); // number — caller never mentions CircleClass or RectClass
The never assignment is your compile-time seatbelt {Gán never là dây an toàn compile-time}: add { kind: 'triangle'; base: number; height: number } to ShapeInput without a case 'triangle' and the build fails {thêm variant vào ShapeInput mà không có case tương ứng thì build fail}. Prefer this over stringly switch (type as string) scattered in components {Ưu tiên cách này hơn switch (type as string) rải trong component}.
For open-ended plugin lists (user-defined handlers), a discriminated union is the wrong tool — use a registry map (next section) {Với danh sách plugin mở (handler do user định nghĩa), discriminated union không phù hợp — dùng registry map (phần sau)}.
Abstract Factory {Abstract Factory}
An Abstract Factory produces a coherent family: once you pick light or dark, every widget you create matches that theme {Abstract Factory tạo một họ nhất quán: chọn light hay dark một lần, mọi widget sinh ra cùng theme}. The client depends on the factory interface, not on LightButton vs DarkButton {Client phụ thuộc interface factory, không phụ thuộc LightButton vs DarkButton}:
interface Button {
render(): string;
}
interface TextField {
render(): string;
}
interface Card {
render(): string;
}
interface UiFactory {
createButton(label: string): Button;
createTextField(placeholder: string): TextField;
createCard(children: string): Card;
}
function lightTheme(): UiFactory {
return {
createButton: (label) => ({ render: () => `<button class="lt">${label}</button>` }),
createTextField: (ph) => ({ render: () => `<input class="lt" placeholder="${ph}" />` }),
createCard: (children) => ({ render: () => `<div class="lt-card">${children}</div>` }),
};
}
function darkTheme(): UiFactory {
return {
createButton: (label) => ({ render: () => `<button class="dk">${label}</button>` }),
createTextField: (ph) => ({ render: () => `<input class="dk" placeholder="${ph}" />` }),
createCard: (children) => ({ render: () => `<div class="dk-card">${children}</div>` }),
};
}
function buildLoginForm(ui: UiFactory): string {
const user = ui.createTextField('Email');
const pass = ui.createTextField('Password');
const submit = ui.createButton('Sign in');
return ui.createCard([user.render(), pass.render(), submit.render()].join(''));
}
const themeName = process.env.UI_THEME === 'dark' ? 'dark' : 'light';
const ui = themeName === 'dark' ? darkTheme() : lightTheme();
buildLoginForm(ui); // entire screen shares one theme — no mixed classes
Another common family is platform adapters — createIosBridge() vs createWebBridge() each returning { storage, analytics, push } with matching semantics {Họ khác thường gặp là platform adapter — createIosBridge() vs createWebBridge() mỗi cái trả { storage, analytics, push } cùng ngữ nghĩa}. The win is consistency under change: swap the factory at the composition root, not every product file {Lợi ích là nhất quán khi đổi: đổi factory ở composition root, không sửa từng file product}.
Real web use cases {Use case web thực tế}
- API clients per environment —
createApiClient({ env: 'prod' | 'staging' })wires base URL, mocks, and auth once {API client theo môi trường —createApiClient({ env: 'prod' | 'staging' })gắn base URL, mock và auth một lần}. - Component / element factories — design systems map
variant: 'primary' | 'ghost'to class names or shadow DOM templates {Factory component/element — design system mapvariant: 'primary' | 'ghost'sang class hoặc template shadow DOM}. - Parser / serializer selection —
createCodec('json' | 'msgpack')for workers, IndexedDB blobs, or WebSocket frames {Chọn parser/serializer —createCodec('json' | 'msgpack')cho worker, blob IndexedDB hay frame WebSocket}. - Test fixtures —
createUser(overrides)builds valid domain objects with defaults; keeps tests readable {Fixture test —createUser(overrides)dựng object domain hợp lệ với default; test dễ đọc}. - Feature modules — Abstract Factory for “analytics backends” where
track(),identify(), andflush()must all come from the same vendor {Module tính năng — Abstract Factory cho backend analytics khitrack(),identify(),flush()phải cùng vendor}.
Pitfalls & anti-patterns {Pitfalls & anti-pattern}
Needless factory — a function that only does return new Foo() adds indirection with zero benefit {Factory thừa — function chỉ return new Foo() thêm indirection không lợi ích}. Inline new until construction gains rules {Giữ new cho tới khi khởi tạo có quy tắc}.
Giant god-factory — one 400-line createEverything() that knows about HTTP, DOM, and PDFs {God-factory — một createEverything() 400 dòng biết HTTP, DOM và PDF}. Split by domain or replace the switch with a registry {Tách theo domain hoặc thay switch bằng registry}:
type NotifierKind = 'email' | 'sms' | 'push';
interface Notifier {
send(to: string, body: string): Promise<void>;
}
const notifierRegistry: Record<NotifierKind, () => Notifier> = {
email: () => ({ send: async (to, body) => { /* ... */ } }),
sms: () => ({ send: async (to, body) => { /* ... */ } }),
push: () => ({ send: async (to, body) => { /* ... */ } }),
};
function createNotifier(kind: NotifierKind): Notifier {
return notifierRegistry[kind]();
}
Leaky unions downstream — returning Circle | Rect | Triangle forces every consumer to switch again {Union rò rỉ — trả Circle | Rect | Triangle buộc mọi consumer switch lại}. Prefer a shared Shape interface with behavior (area(), render()) so callers stay ignorant of concrete kinds {Ưu tiên Shape interface chung với hành vi (area(), render()) để caller không biết kind cụ thể}.
Abstract Factory overkill — two unrelated singletons do not need a factory family; a single createX() is enough {Abstract Factory quá đà — hai singleton không liên quan không cần họ factory; một createX() là đủ}.
Cheat sheet {Bảng tra nhanh}
// Simple factory — hide construction + config
function createClient(baseUrl: string): HttpClient { /* ... */ }
// Discriminated union — closed variants + exhaustiveness
function createShape(input: ShapeInput): Shape {
switch (input.kind) { /* ... */ default: const _: never = input; }
}
// Registry — open plugin lists, map kind → builder
const registry: Record<Kind, () => Product> = { /* ... */ };
// Abstract Factory — consistent family
interface UiFactory { createButton(): Button; createInput(): Input; }
function darkTheme(): UiFactory { /* ... */ }
Decision: one object, env-specific config → function factory; closed variants → discriminated union + never; many kinds, pluggable → registry; families that must match → Abstract Factory {Quyết định: một object, config theo env → function factory; biến thể đóng → discriminated union + never; nhiều kind, plugin → registry; họ phải khớp → Abstract Factory}.
Bài tập / Exercises
1. Add a triangle variant to ShapeInput and extend createShape with an exhaustive switch {Thêm biến thể triangle vào ShapeInput và mở rộng createShape với switch exhaustive}.
Solution {Lời giải}
type ShapeInput =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
interface Shape {
readonly kind: ShapeInput['kind'];
area(): number;
}
function createShape(input: ShapeInput): Shape {
switch (input.kind) {
case 'circle':
return { kind: 'circle', area: () => Math.PI * input.radius ** 2 };
case 'rect':
return { kind: 'rect', area: () => input.width * input.height };
case 'triangle':
return { kind: 'triangle', area: () => (input.base * input.height) / 2 };
default: {
const _exhaustive: never = input;
return _exhaustive;
}
}
}2. Refactor this scattered creation into a registry map (no new in callers) {Refactor đoạn tạo rải rác sau thành registry map (caller không new)}:
// before — duplicated switch in two files
function pickNotifier(kind: 'email' | 'sms') {
if (kind === 'email') return new EmailNotifier();
return new SmsNotifier();
}
Solution {Lời giải}
interface Notifier {
send(to: string, body: string): Promise<void>;
}
class EmailNotifier implements Notifier {
async send(to: string, body: string) { /* ... */ }
}
class SmsNotifier implements Notifier {
async send(to: string, body: string) { /* ... */ }
}
type NotifierKind = 'email' | 'sms';
const notifierRegistry: Record<NotifierKind, () => Notifier> = {
email: () => new EmailNotifier(),
sms: () => new SmsNotifier(),
};
export function createNotifier(kind: NotifierKind): Notifier {
return notifierRegistry[kind]();
}3. Implement a tiny Abstract Factory with two themes (light / dark) that each provide createBadge(text) and createChip(text) returning { html: string } {Cài Abstract Factory nhỏ với hai theme (light / dark), mỗi theme có createBadge(text) và createChip(text) trả { html: string }}.
Solution {Lời giải}
interface Widget {
html: string;
}
interface ThemeFactory {
createBadge(text: string): Widget;
createChip(text: string): Widget;
}
function lightTheme(): ThemeFactory {
return {
createBadge: (text) => ({ html: `<span class="lt-badge">${text}</span>` }),
createChip: (text) => ({ html: `<span class="lt-chip">${text}</span>` }),
};
}
function darkTheme(): ThemeFactory {
return {
createBadge: (text) => ({ html: `<span class="dk-badge">${text}</span>` }),
createChip: (text) => ({ html: `<span class="dk-chip">${text}</span>` }),
};
}
function renderTags(ui: ThemeFactory): string {
return [ui.createBadge('New'), ui.createChip('Beta')].map((w) => w.html).join('');
}4. Write createApiClient(env: 'dev' | 'prod') that returns the same ApiClient interface but uses a mock delay in dev and real fetch in prod {Viết createApiClient(env: 'dev' | 'prod') trả cùng interface ApiClient nhưng dev dùng mock delay, prod dùng fetch thật}.
Solution {Lời giải}
interface ApiClient {
get(path: string): Promise<unknown>;
}
function createApiClient(env: 'dev' | 'prod'): ApiClient {
const base = env === 'prod' ? 'https://api.example.com' : 'http://localhost:3000';
if (env === 'dev') {
return {
async get(path: string) {
await new Promise((r) => setTimeout(r, 50));
return { path, mocked: true };
},
};
}
return {
async get(path: string) {
const res = await fetch(`${base}${path}`);
if (!res.ok) throw new Error(String(res.status));
return res.json();
},
};
}Callers narrow with a schema (e.g. Zod) at the boundary — not inside the factory {Caller thu hẹp bằng schema (vd Zod) ở biên — không nhét vào factory}.
5. List three places in a typical React/Vite app where a factory removes import coupling to concrete implementations {Liệt kê ba chỗ trong app React/Vite điển hình mà factory bỏ coupling import tới implementation cụ thể}.
Solution {Lời giải}
Examples {Ví dụ}:
main.tsx—createQueryClient()/createRouter()with env-specific config {main.tsx—createQueryClient()/createRouter()với config theo env}.lib/analytics.ts—createAnalytics()picks Segment vs noop for local dev {lib/analytics.ts—createAnalytics()chọn Segment vs noop khi dev local}.- Test setup —
createRenderWrapper()injects providers without importing production singletons {Test setup —createRenderWrapper()inject provider không import singleton production}.
Stretch {Nâng cao}: combine a discriminated union and a registry: createParser(input: { kind: 'json' } | { kind: 'csv'; delimiter: string }) dispatches through parserRegistry[kind] while keeping exhaustive typing on input {kết hợp discriminated union và registry: createParser(input: ...) dispatch qua parserRegistry[kind] vẫn giữ kiểu exhaustive trên input}.
Key takeaways {Điểm chính}
- Factories centralize creation so callers depend on interfaces, not concrete classes {Factory gom khởi tạo để caller phụ thuộc interface, không phải class cụ thể}.
- In TypeScript, a function returning an interface is usually enough — classes are an implementation detail {Trong TS, function trả interface thường đủ — class là chi tiết implementation}.
- Discriminated unions +
nevergive compile-time safety for closed variant sets {Discriminated union +nevercho an toàn compile-time với tập biến thể đóng}. - Abstract Factory keeps families (theme, platform, vendor) consistent {Abstract Factory giữ họ (theme, platform, vendor) nhất quán}.
- Avoid needless wrappers, god-factories, and leaky unions that push
switchback to every caller {Tránh wrapper thừa, god-factory, và union rò rỉ đẩyswitchlại mọi caller}.
Next up {Tiếp theo}
Part 3 — Builder & Fluent APIs — construct complex objects step by step without telescoping constructors or giant option bags {Phần 3 — Builder & Fluent API — dựng object phức tạp từng bước, không cần constructor lồng nhau hay bag option khổng lồ}. ← Part 1 — Singleton & the Module Pattern