jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

Đâ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. Ở Phần 1 — Singleton & Module Pattern ta đã gom một instance dùng chung; ở đây ta gom cách sinh object mới.

Phần 2/10 — Design Patterns in TypeScript. Trước: Tiếp: Mỗi phần kết thúc bằng bài tập; hãy làm, đừng chỉ đọc.

Nếu UI, API layer hay test setup của bạn đầy new ConcreteThing()switch (type) { case 'a': return new A() ... }, mọi caller đang dính chặt constructor. Đổi implementation, thêm biến thể, hay đổi môi trường — bạn sửa code ở mười chỗ. 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.


Ý đồ

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. 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.

Client create(type) Factory decides which ProductA ProductB ProductC all implement one Product interface
The client asks for a product by kind; the factory picks the concrete class

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. Bạn không cần factory cho mọi new — ta sẽ chỉ ra những cái thừa.


Factory function vs new

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');

Vì sao thắng new FetchClient() khắp nơi:

  • Khởi tạo một chỗ — base URL, header auth, retry policy nằm trong createHttpClient.
  • Dễ thay — test truyền HttpClient giả; staging dùng mock server.
  • Không nghi thức class — type system TS quan tâm shape, không phải extends.

Chỉ dùng class bên trong factory khi cần method instance với state private hoặc prototype chain thật. Mặt trước vẫn là interface.


Factory discriminated union

Khi biến thể là tập đóng, model input bằng discriminated union và để switch thu hẹp kiểu. 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

Gán neverdây an toàn compile-time: thêm variant vào ShapeInput mà không có case tương ứng thì build fail. Ưu tiên cách này hơn switch (type as string) rải trong component.

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 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. 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

Họ khác thường gặp là platform adaptercreateIosBridge() vs createWebBridge() mỗi cái trả { storage, analytics, push } cùng ngữ nghĩa. Lợi ích là nhất quán khi đổi: đổi factory ở composition root, không sửa từng file product.


Use case web thực tế

  • API client theo môi trườngcreateApiClient({ env: 'prod' | 'staging' }) gắn base URL, mock và auth một lần.
  • Factory component/element — design system map variant: 'primary' | 'ghost' sang class hoặc template shadow DOM.
  • Chọn parser/serializercreateCodec('json' | 'msgpack') cho worker, blob IndexedDB hay frame WebSocket.
  • Fixture testcreateUser(overrides) dựng object domain hợp lệ với default; test dễ đọc.
  • Module tính năng — Abstract Factory cho backend analytics khi track(), identify(), flush() phải cùng vendor.

Pitfalls & anti-pattern

Factory thừa — function chỉ return new Foo() thêm indirection không lợi ích. Giữ new cho tới khi khởi tạo có quy tắc.

God-factory — một createEverything() 400 dòng biết HTTP, DOM và PDF. 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]();
}

Union rò rỉ — trả Circle | Rect | Triangle buộc mọi consumer switch lại. Ưu tiên Shape interface chung với hành vi (area(), render()) để caller không biết kind cụ thể.

Abstract Factory quá đà — hai singleton không liên quan không cần họ factory; một createX() là đủ.


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 { /* ... */ }

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. Thêm biến thể triangle vào ShapeInput và mở rộng createShape với switch exhaustive.

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 đ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();
}
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. Cài Abstract Factory nhỏ với hai theme (light / dark), mỗi theme có createBadge(text)createChip(text) trả { html: string }.

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. Viết createApiClient(env: 'dev' | 'prod') trả cùng interface ApiClient nhưng dev dùng mock delay, prod dùng fetch thật.

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();
    },
  };
}

Caller thu hẹp bằng schema (vd Zod) ở biên — không nhét vào factory.

5. Liệt kê ba chỗ trong app React/Vite điển hình mà factory bỏ coupling import tới implementation cụ thể.

Lời giải

Ví dụ:

  1. main.tsxcreateQueryClient() / createRouter() với config theo env.
  2. lib/analytics.tscreateAnalytics() chọn Segment vs noop khi dev local.
  3. Test setup — createRenderWrapper() inject provider không import singleton production.

Nâng cao:kết hợp discriminated union registry: createParser(input: ...) dispatch qua parserRegistry[kind] vẫn giữ kiểu exhaustive trên input.


Điểm chính

  • Factory gom khởi tạo để caller phụ thuộc interface, không phải class cụ thể.
  • Trong TS, function trả interface thường đủ — class là chi tiết implementation.
  • Discriminated union + never cho an toàn compile-time với tập biến thể đóng.
  • Abstract Factory giữ họ (theme, platform, vendor) nhất quán.
  • Tránh wrapper thừa, god-factory, và union rò rỉ đẩy switch lại mọi caller.

Tiếp theo

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ồ.