jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Design Patterns in TypeScript · Part 4 — Strategy

Swap an algorithm at runtime without touching its caller: the Strategy pattern, why a map of functions is the idiomatic TS form, replacing sprawling if/switch, and injecting behavior for testability.

Phần 4/10 trong series Design Patterns in TypeScript. Trước: Tiếp:

Đây là Phần 4 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.

Bạn đã thấy builder lắp object từng bước (Phần 3). Giờ ta xử lý một mùi khác: hàm cứ thêm nhánh if hoặc switch mỗi khi product muốn thêm một “cách làm X”. Strategy nói: tách mỗi thuật toán thành một đơn vị riêng và cho caller đổi cái nào chạy — mà không sửa lại caller.


Ý đồ

Strategy định nghĩa một họ thuật toán, đóng gói từng cái, và cho chúng thay thế lẫn nhau được. Client (Context) ủy quyền cho một strategy object hoặc function thay vì nhúng logic rẽ nhánh. Bạn dùng khi cách làm thay đổi nhưng việc làm (tên thao tác, input, kiểu trả về) vẫn ổn định: thứ tự sắp xếp, quy tắc giảm giá, pipeline validate, backoff retry, cổng thanh toán, formatter theo locale.

Context has a Strategy Strategy interface: run() SortByDate SortByPrice SortByName swap implementation at runtime
Context delegates to one Strategy; swap the implementation without changing the caller

Lợi ích là Open/Closed thực tế: thêm biến thể bằng cách thêm strategy mới, không sửa switch 200 dòng. Rủi ro là over-engineer một if đơn thành registry không ai cần — ta sẽ nói thẳng điều đó.


Dạng kinh điển (interface)

Ảnh sách giáo khoa: interface Strategy, class cụ thể, và Context giữ một strategy và gọi nó:

interface SortStrategy<T> {
  sort(items: readonly T[]): T[];
}

class SortByPrice implements SortStrategy<{ price: number }> {
  sort(items: readonly { price: number }[]) {
    return [...items].sort((a, b) => a.price - b.price);
  }
}

class SortByName implements SortStrategy<{ name: string }> {
  sort(items: readonly { name: string }[]) {
    return [...items].sort((a, b) => a.name.localeCompare(b.name));
  }
}

class ProductListContext {
  constructor(private strategy: SortStrategy<{ price: number; name: string }>) {}

  setStrategy(strategy: SortStrategy<{ price: number; name: string }>) {
    this.strategy = strategy;
  }

  displaySorted(items: readonly { price: number; name: string }[]) {
    return this.strategy.sort(items);
  }
}

Trung thành với Gang of Four và khớp ngôn ngữ mà class là abstraction mặc định. Trong code web TypeScript, bạn gặp dạng này khi strategy mang state (API key, config) hoặc inject qua DI container dưới dạng class token. Với transform một dòng không state, phần sau thường hợp hơn.


Dạng idiomatic TS: function

Trong JS và TS, strategy thường chỉ là một function có chữ ký cố định. Record<Key, StrategyFn> (hoặc Map) thay switch và gom chọn strategy một chỗ.

Trước — mỗi kiểu sort mới sửa cùng một hàm:

type SortKey = 'price' | 'name' | 'date';

interface Product {
  name: string;
  price: number;
  createdAt: Date;
}

function sortProducts(items: readonly Product[], mode: SortKey): Product[] {
  const copy = [...items];
  switch (mode) {
    case 'price':
      return copy.sort((a, b) => a.price - b.price);
    case 'name':
      return copy.sort((a, b) => a.name.localeCompare(b.name));
    case 'date':
      return copy.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
    default: {
      const _exhaustive: never = mode;
      return _exhaustive;
    }
  }
}

Sau — thêm key và function; context vẫn “ngu” đúng nghĩa:

type SortStrategy = (items: readonly Product[]) => Product[];

const sortStrategies: Record<SortKey, SortStrategy> = {
  price: (items) => [...items].sort((a, b) => a.price - b.price),
  name: (items) => [...items].sort((a, b) => a.name.localeCompare(b.name)),
  date: (items) =>
    [...items].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()),
};

function sortProducts(
  items: readonly Product[],
  mode: SortKey,
  strategies: Record<SortKey, SortStrategy> = sortStrategies,
): Product[] {
  const strategy = strategies[mode];
  if (!strategy) {
    throw new Error(`Unknown sort mode: ${mode}`);
  }
  return strategy(items);
}

never ở nhánh default giúp switch exhaustive khi bắt buộc giữ switch; map + union SortKey thường làm nhánh đó thừa. Điểm senior: registry function, không phải cây điều kiện phình ra.


Inject strategy

Logic chọn strategy và logic thực thi nên tách. Truyền strategy (hoặc cả registry) qua tham số để caller, test, và feature flag thay hành vi:

type DiscountStrategy = (subtotal: number) => number;

const noDiscount: DiscountStrategy = (subtotal) => subtotal;

const tenPercentOff: DiscountStrategy = (subtotal) =>
  Math.round(subtotal * 0.9 * 100) / 100;

export function checkoutTotal(
  subtotal: number,
  applyDiscount: DiscountStrategy = noDiscount,
): number {
  return applyDiscount(subtotal);
}

// test — no DOM, no env, deterministic:
checkoutTotal(100, tenPercentOff); // 90
checkoutTotal(100, (n) => n); // 100 with inline fake strategy

Tham số mặc định cho hành vi production; test truyền fake. Cùng kiểu dependency injection như Phần 1 — ở đây “dependency” là hành vi, không phải instance service. Phần 10 đi sâu cách nối ở composition root.


Use case web thực tế

  • Sắp xếp / lọcSortKey → hàm so sánh; preset filter trên bảng dữ liệu.
  • Giá / giảm giá — rule xếp chồng thành strategy compose trái sang phải.
  • Validate — strategy theo field hoặc form; đổi strict vs lenient khi test.
  • Retry / backofflinear, exponential, fullJitter là function có tên sau một API retry(policy, fn).
  • Cổng thanh toáncharge(amount, provider) với provider là strategy object có authorize / capture.
  • Formatter theo localeformatCurrency(n, localeStrategy) thay vì if (locale === 'vi') rải trong helper JSX.

Strategy vs chỉ truyền callback

Trong JS, “Strategy” thường chính là higher-order function: truyền (item) => boolean hoặc (err) => delayMs là đủ. Ổn thế — không cần hộp UML cho mọi callback.

Dùng kiểu có tên + registry khi:

  • Tập biến thể đóng và biết trước (union key) và bạn muốn kiểm tra exhaustive.
  • Nhiều call site dùng chung catalog strategy.
  • Strategy là sản phẩm có tên (cổng thanh toán, định dạng export) không phải lambda vô danh.

Giữ callback trần khi chỉ một call site và function là predicate hoặc mapper một lần.


Cạm bẫy

  • Rò logic chọn khắp nơiif (mode === 'price') copy trong UI, API, test thay vì một getStrategy(mode) hoặc lookup registry.
  • Chữ ký strategy không tương thích — nếu strategyA cần (user, cart)strategyB cần (sku, warehouse), bạn không có một interface Strategy; bạn gộp hai bài toán khác nhau.
  • Over-abstract một if — ba dòng không cần StrategyFactoryRegistry.
  • Strategy global mutablesetStrategy() trên module singleton khiến test phụ thuộc thứ tự; ưu tiên truyền strategy mỗi lần gọi hoặc theo scope request.

Bảng tra nhanh

// Idiomatic: function + registry
type Policy = 'linear' | 'exponential';
type BackoffFn = (attempt: number) => number;

const backoff: Record<Policy, BackoffFn> = {
  linear: (n) => n * 100,
  exponential: (n) => 100 * 2 ** n,
};

function retryAfter(attempt: number, policy: Policy, table = backoff): number {
  return table[policy](attempt);
}

// Injectable for tests
function formatPrice(cents: number, fmt: (n: number) => string = (n) => `$${n}`) {
  return fmt(cents);
}

// Class form when strategy holds state / lifecycle
interface PaymentStrategy {
  charge(cents: number): Promise<{ id: string }>;
}

Quyết định: callback một lần → truyền function; catalog biến thể → Record<Key, Fn>; có state / IO → class hoặc object strategy; test → inject tham số mặc định.


Bài tập / Exercises

1. Refactor switch dưới thành Record<ExportFormat, ExportStrategy> và wrapper mỏng exportData(rows, format).

type ExportFormat = 'csv' | 'json';
type Row = { id: string; label: string };

function exportData(rows: readonly Row[], format: ExportFormat): string {
  switch (format) {
    case 'csv':
      return rows.map((r) => `${r.id},${r.label}`).join('\n');
    case 'json':
      return JSON.stringify(rows);
    default: {
      const _exhaustive: never = format;
      return _exhaustive;
    }
  }
}
Lời giải
type ExportFormat = 'csv' | 'json';
type Row = { id: string; label: string };
type ExportStrategy = (rows: readonly Row[]) => string;

const exportStrategies: Record<ExportFormat, ExportStrategy> = {
  csv: (rows) => rows.map((r) => `${r.id},${r.label}`).join('\n'),
  json: (rows) => JSON.stringify(rows),
};

function exportData(
  rows: readonly Row[],
  format: ExportFormat,
  strategies: Record<ExportFormat, ExportStrategy> = exportStrategies,
): string {
  return strategies[format](rows);
}

2. Thêm strategy xml vào registry bài 1 mà không sửa thân exportData (chỉ mở rộng registry và kiểu).

Lời giải
type ExportFormat = 'csv' | 'json' | 'xml';

const exportStrategies: Record<ExportFormat, ExportStrategy> = {
  csv: (rows) => rows.map((r) => `${r.id},${r.label}`).join('\n'),
  json: (rows) => JSON.stringify(rows),
  xml: (rows) =>
    `<rows>${rows.map((r) => `<row id="${r.id}">${r.label}</row>`).join('')}</rows>`,
};

// exportData unchanged — Open/Closed at the registry edge

3. computeShipping(weightKg) hard-code weightKg * 2. Inject ShippingStrategy và viết test dùng fake flat-rate.

Lời giải
type ShippingStrategy = (weightKg: number) => number;

const standardShipping: ShippingStrategy = (w) => w * 2;

export function computeShipping(
  weightKg: number,
  strategy: ShippingStrategy = standardShipping,
): number {
  return strategy(weightKg);
}

// test
const flatFive: ShippingStrategy = () => 5;
computeShipping(100, flatFive); // 5 — no dependency on weight formula

4. UI truyền sortMode: string từ query param. Viết type guard isSortKey(value: string): value is SortKeygetSortStrategy(mode: string) throw khi key lạ — gom chọn trong một module.

Lời giải
type SortKey = 'price' | 'name';

function isSortKey(value: string): value is SortKey {
  return value === 'price' || value === 'name';
}

function getSortStrategy(mode: string): SortStrategy {
  if (!isSortKey(mode)) {
    throw new Error(`Invalid sort mode: ${mode}`);
  }
  return sortStrategies[mode];
}

// route handler: getSortStrategy(searchParams.get('sort') ?? 'price')

Nâng cao:cài retry<T>(fn, options) với options.backoff là strategy BackoffFn; có linearexponential trong registry; unit-test retry chờ đúng chuỗi mà fake clock mong đợi (stub setTimeout hoặc inject strategy sleep).

Lời giải
type BackoffFn = (attempt: number) => number;
type SleepFn = (ms: number) => Promise<void>;

const backoffPolicies: Record<'linear' | 'exponential', BackoffFn> = {
  linear: (n) => n * 10,
  exponential: (n) => 10 * 2 ** n,
};

async function retry<T>(
  fn: () => Promise<T>,
  options: {
    maxAttempts: number;
    policy: keyof typeof backoffPolicies;
    sleep?: SleepFn;
    backoff?: Record<keyof typeof backoffPolicies, BackoffFn>;
  },
): Promise<T> {
  const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
  const table = options.backoff ?? backoffPolicies;
  let lastError: unknown;
  for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (attempt === options.maxAttempts) break;
      await sleep(table[options.policy](attempt));
    }
  }
  throw lastError;
}

// test: collect delays via fake sleep
const delays: number[] = [];
await retry(async () => { throw new Error('fail'); }, {
  maxAttempts: 3,
  policy: 'linear',
  sleep: async (ms) => { delays.push(ms); },
});
// delays === [10, 20] — linear backoff for attempts 1 and 2

Điểm chính

  • Strategy = đóng gói thuật toán thay thế được; caller ổn định khi biến thể tăng.
  • Trong TS, ưu tiên Record<Key, Fn> hơn if/switch lan khi tập biến thể là union biết trước.
  • Inject strategy (tham số mặc định hoặc đối số) để test và feature flag đổi hành vi không cần global.
  • Không phải callback nào cũng là “pattern” — dùng registry khi catalog dùng chung và kiểu exhaustive quan trọng.
  • Tránh logic chọn rò rỉchữ ký strategy không khớp.

Tiếp theo

Phần 5 — Observer & Pub/Sub: tách producer và consumer khi nhiều listener phản ứng cùng một event — kênh có kiểu, DOM event, và subscription store không thành spaghetti callback.