【TypeScript】デザインパターン完全実装ガイド|Singleton・Factory・Builder・Strategy・Observer・Command・State を型安全に実装する

【TypeScript】デザインパターン完全実装ガイド|Singleton・Factory・Builder・Strategy・Observer・Command・State を型安全に実装する TypeScript

デザインパターンは「設計の共通語」であり、チーム開発での意思疎通を大幅に効率化します。TypeScript でパターンを実装すると、型システムがパターンの制約を自動的に強制するため、実装ミスをコンパイル時に検出できます。

この記事では GoF デザインパターンから厳選した 7 つを、TypeScript の型機能を最大限活用した実践的な実装で解説します。「なぜそのパターンを使うか」「TypeScript では何が変わるか」を意識しながら読んでください。

この記事で実装するパターン

  • Singleton — 型安全なシングルトン(ジェネリクス版)
  • Factory Method — 条件分岐のない型安全な生成ロジック
  • Abstract Factory — 関連オブジェクト群を一括生成
  • Builder — fluent API による型安全なオブジェクト構築
  • Strategy — アルゴリズムの差し替えを型で保証
  • Observer(TypedEventEmitter) — イベント名と型を紐づけた型安全な監視
  • Command — 操作の記録・Undo を型安全に実装
  • State Machine — Discriminated Union による型安全な状態遷移
分類 パターン 解決する問題
生成(Creational) Singleton インスタンスが 1 つだけであることを保証
生成(Creational) Factory Method 生成ロジックをサブクラス/関数に委譲
生成(Creational) Abstract Factory 関連オブジェクト群をまとめて生成
生成(Creational) Builder 複雑なオブジェクトを段階的に構築
振る舞い(Behavioral) Strategy アルゴリズムをオブジェクトとして差し替え可能にする
振る舞い(Behavioral) Observer オブジェクトの状態変化を複数の監視者に通知
振る舞い(Behavioral) Command 操作をオブジェクト化して記録・取り消しを可能にする
振る舞い(Behavioral) State Machine 状態と遷移を型で完全に定義し、無効な遷移を防ぐ
スポンサーリンク

Singleton パターン

Singleton はアプリケーション全体でインスタンスを 1 つだけ保持したいとき(DB 接続・設定管理・ロガーなど)に使います。TypeScript では プライベートコンストラクタ + static メソッドで実装できます。

// ─── 基本実装 ───
class AppConfig {
  private static instance: AppConfig | null = null;

  private constructor(
    public readonly apiUrl:    string,
    public readonly maxRetry:  number,
    public readonly debug:     boolean
  ) {}

  static getInstance(): AppConfig {
    if (AppConfig.instance === null) {
      AppConfig.instance = new AppConfig(
        process.env.API_URL ?? "https://api.example.com",
        Number(process.env.MAX_RETRY ?? 3),
        process.env.NODE_ENV === "development"
      );
    }
    return AppConfig.instance;
  }

  // テスト用にリセットできるようにする
  static reset(): void {
    AppConfig.instance = null;
  }
}

// 利用例
const config1 = AppConfig.getInstance();
const config2 = AppConfig.getInstance();
console.log(config1 === config2); // true(同じインスタンス)

// new AppConfig(...) → コンパイルエラー(private constructor)

ジェネリクス版 Singleton(汎用ファクトリー)

// ─── どんなクラスでも Singleton にできる汎用実装 ───
type Constructor<T> = new (...args: unknown[]) => T;

class SingletonRegistry {
  private static instances = new Map<Constructor<unknown>, unknown>();

  static get<T>(Ctor: Constructor<T>): T {
    if (!SingletonRegistry.instances.has(Ctor)) {
      SingletonRegistry.instances.set(Ctor, new Ctor());
    }
    return SingletonRegistry.instances.get(Ctor) as T;
  }

  static reset<T>(Ctor: Constructor<T>): void {
    SingletonRegistry.instances.delete(Ctor);
  }
}

// 利用例
class Logger { log(msg: string) { console.log(`[LOG] ${msg}`); } }
class Database { query(sql: string) { /* ... */ } }

const logger = SingletonRegistry.get(Logger);   // Logger 型
const db     = SingletonRegistry.get(Database); // Database 型
Singleton とテスト容易性
Singleton はグローバル状態を持つため、テストの独立性を損なうことがあります。本番コードでは Singleton を直接参照せず、依存注入(DI: Dependency Injection)パターンと組み合わせてテスト時にモックを差し込めるよう設計するのがベストプラクティスです。テスト用の reset() メソッドを用意するのも有効です。

Factory Method パターン

Factory Method は「オブジェクトの生成をサブクラスや専用関数に委譲する」パターンです。TypeScript では Discriminated Union + 型安全なマップif/elseswitch なしに実装できます。

// ─── 通知サービスの Factory Method ───

// 各通知チャネルが実装するインターフェース
interface NotificationService {
  send(to: string, message: string): Promise<void>;
  readonly channel: NotificationChannel;
}

type NotificationChannel = "email" | "sms" | "push" | "slack";

// 具体的な実装クラス
class EmailNotification implements NotificationService {
  readonly channel = "email" as const;
  async send(to: string, message: string): Promise<void> {
    console.log(`Email → ${to}: ${message}`);
  }
}

class SmsNotification implements NotificationService {
  readonly channel = "sms" as const;
  async send(to: string, message: string): Promise<void> {
    console.log(`SMS → ${to}: ${message}`);
  }
}

class SlackNotification implements NotificationService {
  readonly channel = "slack" as const;
  constructor(private readonly webhookUrl: string) {}
  async send(to: string, message: string): Promise<void> {
    console.log(`Slack → #${to}: ${message}`);
  }
}

// ─── Factory: switch なしに型安全な生成 ───
type NotificationConfig =
  | { channel: "email" }
  | { channel: "sms" }
  | { channel: "slack"; webhookUrl: string };

function createNotificationService(
  config: NotificationConfig
): NotificationService {
  switch (config.channel) {
    case "email": return new EmailNotification();
    case "sms":   return new SmsNotification();
    case "slack": return new SlackNotification(config.webhookUrl);
  }
}

// ─── TypeScript が網羅性をチェック ───
// NotificationChannel に "push" を追加した場合、
// switch に case "push" が欠けているとコンパイルエラー

// 利用例
const emailSvc = createNotificationService({ channel: "email" });
const slackSvc = createNotificationService({
  channel: "slack",
  webhookUrl: "https://hooks.slack.com/...",
});
await emailSvc.send("user@example.com", "登録完了しました");

Abstract Factory パターン

Abstract Factory は「関連するオブジェクト群をまとめて生成する」パターンです。TypeScript では インターフェースで Factory の「契約」を定義し、実装を差し替えられる設計にします。

// ─── UI コンポーネントの Abstract Factory ───
// (Web用・Native用・Test用の3種類を切り替える例)

// 各コンポーネントのインターフェース
interface Button   { render(): string; onClick(handler: () => void): void; }
interface TextInput { render(): string; getValue(): string; }
interface Dialog    { show(title: string, message: string): void; }

// ─── Abstract Factory インターフェース ───
interface UIFactory {
  createButton():    Button;
  createTextInput(): TextInput;
  createDialog():    Dialog;
}

// ─── Web 向け実装 ───
class WebButton implements Button {
  render() { return "<button>Click</button>"; }
  onClick(handler: () => void) { /* addEventListener */ handler; }
}
class WebTextInput implements TextInput {
  render() { return "<input type=\"text\">" ; }
  getValue() { return ""; /* document.querySelector... */ }
}
class WebDialog implements Dialog {
  show(title: string, message: string) { alert(`${title}: ${message}`); }
}

class WebUIFactory implements UIFactory {
  createButton()    { return new WebButton(); }
  createTextInput() { return new WebTextInput(); }
  createDialog()    { return new WebDialog(); }
}

// ─── Test 向け実装(モック)───
class MockButton implements Button {
  rendered = false;
  clickHandlers: Array<() => void> = [];
  render() { this.rendered = true; return ""; }
  onClick(handler: () => void) { this.clickHandlers.push(handler); }
}

class MockUIFactory implements UIFactory {
  readonly buttons:    MockButton[]   = [];
  createButton() {
    const b = new MockButton(); this.buttons.push(b); return b;
  }
  createTextInput() { return new WebTextInput(); }
  createDialog()    { return { show: () => {} }; }
}

// ─── アプリ本体(Factory に依存し実装に依存しない)───
class LoginForm {
  private button:    Button;
  private emailInput: TextInput;
  private dialog:    Dialog;

  constructor(factory: UIFactory) {
    this.button     = factory.createButton();
    this.emailInput = factory.createTextInput();
    this.dialog     = factory.createDialog();
  }

  mount() {
    this.button.render();
    this.emailInput.render();
    this.button.onClick(() => {
      const email = this.emailInput.getValue();
      this.dialog.show("確認", `${email} でログインしますか?`);
    });
  }
}

// 本番: Web UI
const webForm  = new LoginForm(new WebUIFactory());

// テスト: Mock UI(実際のDOMなしで動作確認)
const mockFactory = new MockUIFactory();
const testForm    = new LoginForm(mockFactory);
testForm.mount();
mockFactory.buttons[0].clickHandlers[0](); // ボタンのクリックをシミュレート

Builder パターン

Builder は「複雑なオブジェクトを段階的に構築する」パターンです。TypeScript では メソッドチェーン(fluent API)+ 型で必須フィールドを保証する高度な実装が可能です。

// ─── HTTPリクエスト Builder ───

type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

interface Request {
  url:     string;
  method:  HttpMethod;
  headers: Record<string, string>;
  body?:   unknown;
  timeout: number;
}

class RequestBuilder {
  private req: Partial<Request> = {
    headers: {},
    timeout: 5000,
  };

  url(url: string): this {
    this.req.url = url;
    return this; // this を返すことでメソッドチェーンが可能
  }

  method(method: HttpMethod): this {
    this.req.method = method;
    return this;
  }

  header(key: string, value: string): this {
    this.req.headers = { ...this.req.headers, [key]: value };
    return this;
  }

  bearer(token: string): this {
    return this.header("Authorization", `Bearer ${token}`);
  }

  json(body: unknown): this {
    this.req.body = body;
    return this.header("Content-Type", "application/json");
  }

  timeout(ms: number): this {
    this.req.timeout = ms;
    return this;
  }

  build(): Request {
    if (!this.req.url)    throw new Error("url は必須です");
    if (!this.req.method) throw new Error("method は必須です");
    return this.req as Request;
  }
}

// 利用例: メソッドチェーンで直感的に組み立てる
const request = new RequestBuilder()
  .url("https://api.example.com/users")
  .method("POST")
  .bearer("eyJhbGciOiJSUzI1NiJ9...")
  .json({ name: "Alice", email: "alice@example.com" })
  .timeout(10_000)
  .build();

型で必須フィールドを強制する高度な Builder

// ─── 型パラメータで「設定済みフィールド」を追跡する ───
// build() は url と method が両方設定された場合のみ呼べる

class TypedRequestBuilder<TSet extends string = never> {
  private req: Partial<Request> = { headers: {}, timeout: 5000 };

  url(url: string): TypedRequestBuilder<TSet | "url"> {
    this.req.url = url;
    return this as unknown as TypedRequestBuilder<TSet | "url">;
  }

  method(method: HttpMethod): TypedRequestBuilder<TSet | "method"> {
    this.req.method = method;
    return this as unknown as TypedRequestBuilder<TSet | "method">;
  }

  bearer(token: string): this {
    this.req.headers = { ...this.req.headers, Authorization: `Bearer ${token}` };
    return this;
  }

  json(body: unknown): this {
    this.req.body = body;
    this.req.headers = { ...this.req.headers, "Content-Type": "application/json" };
    return this;
  }

  // build() は "url" と "method" が TSet に含まれている場合のみ呼べる
  build(
    this: TypedRequestBuilder<TSet & ("url" | "method")>
  ): Request {
    return this.req as Request;
  }
}

// OK: url と method が両方セット済み
const req = new TypedRequestBuilder()
  .url("https://api.example.com/users")
  .method("GET")
  .build(); // OK

// Error: method が未設定のまま build() しようとするとコンパイルエラー
// new TypedRequestBuilder().url("...").build(); → Error
Builder は「段階的な構築」が必要なときに使う
コンストラクタ引数が多い(5つ以上)・引数の組み合わせが複雑・オプション引数が多い場合に Builder が有効です。引数が少なく固定的なら Builder は過設計になります。fluent API の利点は「補完が効く」「途中状態が型で管理される」「テストでの一部省略が容易」です。

Strategy パターン

Strategy は「アルゴリズムをオブジェクトとして分離し、実行時に差し替え可能にする」パターンです。TypeScript では クラスの代わりに関数型(Function Type)でシンプルかつ型安全に実装できます。

// ─── ソート Strategy(関数型アプローチ)───

type SortStrategy<T> = (items: T[]) => T[];

// 各 Strategy の実装(純粋関数)
function bubbleSort<T>(items: T[]): T[] {
  const arr = [...items];
  for (let i = 0; i < arr.length - 1; i++) {
    for (let j = 0; j < arr.length - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
    }
  }
  return arr;
}

function quickSort<T>(items: T[]): T[] {
  if (items.length <= 1) return items;
  const [pivot, ...rest] = items;
  return [
    ...quickSort(rest.filter(x => x <= pivot)),
    pivot,
    ...quickSort(rest.filter(x => x > pivot)),
  ];
}

// Context クラス: Strategy を保持・実行
class Sorter<T> {
  constructor(private strategy: SortStrategy<T>) {}

  setStrategy(strategy: SortStrategy<T>): void {
    this.strategy = strategy;
  }

  sort(items: T[]): T[] {
    return this.strategy(items);
  }
}

// 利用例
const sorter = new Sorter<number>(bubbleSort);
console.log(sorter.sort([3, 1, 4, 1, 5])); // [1, 1, 3, 4, 5]

sorter.setStrategy(quickSort); // 実行時に差し替え
console.log(sorter.sort([3, 1, 4, 1, 5])); // [1, 1, 3, 4, 5]

実務的な例:支払い方法の Strategy

// ─── 支払い Strategy(インターフェース版)───

interface PaymentStrategy {
  readonly name:      string;
  charge(amount: number): Promise<{ success: boolean; transactionId: string }>;
  refund(transactionId: string, amount: number): Promise<boolean>;
}

class CreditCardStrategy implements PaymentStrategy {
  readonly name = "クレジットカード" as const;
  constructor(private cardNumber: string) {}
  async charge(amount: number) {
    // クレジットカード決済API呼び出し
    return { success: true, transactionId: `CC-${Date.now()}` };
  }
  async refund(transactionId: string) { return true; }
}

class PayPalStrategy implements PaymentStrategy {
  readonly name = "PayPal" as const;
  constructor(private email: string) {}
  async charge(amount: number) {
    return { success: true, transactionId: `PP-${Date.now()}` };
  }
  async refund(transactionId: string) { return true; }
}

class OrderService {
  private payment: PaymentStrategy;

  constructor(payment: PaymentStrategy) {
    this.payment = payment;
  }

  setPaymentMethod(strategy: PaymentStrategy): void {
    this.payment = strategy;
  }

  async processOrder(totalAmount: number): Promise<string> {
    const result = await this.payment.charge(totalAmount);
    if (!result.success) throw new Error("決済失敗");
    return result.transactionId;
  }
}

// 利用例
const order = new OrderService(new CreditCardStrategy("4242-4242-4242-4242"));
const txId  = await order.processOrder(3980);

// PayPal に変更
order.setPaymentMethod(new PayPalStrategy("user@example.com"));

Observer パターン(TypedEventEmitter)

Observer は「状態変化を複数の購読者(Observer)に通知する」パターンです。Node.js の EventEmitter は型安全性がないため、TypeScript では イベント名と payload の型をジェネリクスで紐づけた TypedEventEmitterを実装するのが実務標準です。

// ─── 型安全な EventEmitter ───

// イベントマップ: イベント名 → payload 型
type EventMap = Record<string, unknown>;

type Listener<T> = (payload: T) => void;

class TypedEventEmitter<Events extends EventMap> {
  // WeakRef は使わず、Map<string, Set<Listener>> で管理
  private listeners = new Map<
    keyof Events & string,
    Set<Listener<Events[keyof Events]>>
  >();

  on<K extends keyof Events & string>(
    event: K,
    listener: Listener<Events[K]>
  ): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as Listener<Events[keyof Events]>);

    // 登録解除関数を返す(useEffect の cleanup に使える)
    return () => this.off(event, listener);
  }

  off<K extends keyof Events & string>(
    event: K,
    listener: Listener<Events[K]>
  ): void {
    this.listeners.get(event)?.delete(listener as Listener<Events[keyof Events]>);
  }

  once<K extends keyof Events & string>(
    event: K,
    listener: Listener<Events[K]>
  ): void {
    const wrapper: Listener<Events[K]> = (payload) => {
      listener(payload);
      this.off(event, wrapper);
    };
    this.on(event, wrapper);
  }

  emit<K extends keyof Events & string>(
    event: K,
    payload: Events[K]
  ): void {
    this.listeners.get(event)?.forEach(listener => listener(payload as Events[K]));
  }
}

// ─── 利用例:ショッピングカートのイベント ───

// イベントマップの定義
interface CartEvents {
  "item:added":   { productId: string; quantity: number; price: number };
  "item:removed": { productId: string };
  "cart:cleared": undefined;
  "checkout:done": { orderId: string; total: number };
}

const cart = new TypedEventEmitter<CartEvents>();

// イベント名と payload が型補完される
const cleanup = cart.on("item:added", ({ productId, quantity, price }) => {
  // productId: string, quantity: number, price: number
  console.log(`追加: ${productId} × ${quantity} = ${price * quantity}円`);
});

cart.on("checkout:done", ({ orderId, total }) => {
  console.log(`注文完了: #${orderId} 合計 ${total}円`);
});

// emit も型チェックされる
cart.emit("item:added", { productId: "P001", quantity: 2, price: 1980 }); // OK
// cart.emit("item:added", { productId: "P001" }); // Error: quantity と price が欠けている
// cart.emit("unknown:event", {}); // Error: イベント名が存在しない

cleanup(); // on() の戻り値を呼ぶと登録解除

Command パターン

Command は「操作をオブジェクトとして表現し、実行・取り消し・再実行を可能にする」パターンです。テキストエディタの Undo/Redo・トランザクション処理などに使います。

// ─── テキストエディタの Command パターン ───

// Command インターフェース
interface Command {
  execute(): void;
  undo():    void;
  readonly description: string; // デバッグ用
}

// ─── 具体的な Command 実装 ───

class InsertTextCommand implements Command {
  readonly description: string;

  constructor(
    private document: { text: string },
    private position: number,
    private text:     string
  ) {
    this.description = `Insert "${text}" at ${position}`;
  }

  execute(): void {
    const { text, position } = this;
    this.document.text =
      this.document.text.slice(0, position) +
      text +
      this.document.text.slice(position);
  }

  undo(): void {
    const { text, position } = this;
    this.document.text =
      this.document.text.slice(0, position) +
      this.document.text.slice(position + text.length);
  }
}

class DeleteTextCommand implements Command {
  readonly description: string;
  private deletedText = "";

  constructor(
    private document: { text: string },
    private position: number,
    private length:   number
  ) {
    this.description = `Delete ${length} chars at ${position}`;
  }

  execute(): void {
    this.deletedText = this.document.text.slice(
      this.position, this.position + this.length
    );
    this.document.text =
      this.document.text.slice(0, this.position) +
      this.document.text.slice(this.position + this.length);
  }

  undo(): void {
    this.document.text =
      this.document.text.slice(0, this.position) +
      this.deletedText +
      this.document.text.slice(this.position);
  }
}

// ─── CommandHistory(Undo/Redo スタック)───
class CommandHistory {
  private history: Command[] = [];
  private cursor  = -1; // 現在位置(-1 = 何もない)

  execute(command: Command): void {
    // redo スタックを破棄(新しい操作をしたら redo できない)
    this.history = this.history.slice(0, this.cursor + 1);
    command.execute();
    this.history.push(command);
    this.cursor++;
  }

  undo(): void {
    if (this.cursor < 0) return;
    this.history[this.cursor].undo();
    this.cursor--;
  }

  redo(): void {
    if (this.cursor >= this.history.length - 1) return;
    this.cursor++;
    this.history[this.cursor].execute();
  }

  canUndo(): boolean { return this.cursor >= 0; }
  canRedo(): boolean { return this.cursor < this.history.length - 1; }

  getHistory(): readonly string[] {
    return this.history.map(c => c.description);
  }
}

// ─── 利用例 ───
const doc     = { text: "Hello" };
const history = new CommandHistory();

history.execute(new InsertTextCommand(doc, 5, " World"));
console.log(doc.text); // "Hello World"

history.execute(new InsertTextCommand(doc, 11, "!"));
console.log(doc.text); // "Hello World!"

history.undo();
console.log(doc.text); // "Hello World"

history.undo();
console.log(doc.text); // "Hello"

history.redo();
console.log(doc.text); // "Hello World"

State Machine パターン

State Machine は「状態と遷移を明示的に定義し、無効な遷移を防ぐ」パターンです。TypeScript の Discriminated Union と型ガードを使うと、許可されていない状態遷移がコンパイル時エラーになります。

// ─── 注文の状態遷移マシン ───

// 各状態をオブジェクト型で定義
type OrderState =
  | { status: "pending";    createdAt: Date }
  | { status: "paid";       paidAt: Date;       transactionId: string }
  | { status: "shipped";    shippedAt: Date;     trackingCode: string }
  | { status: "delivered";  deliveredAt: Date }
  | { status: "cancelled";  cancelledAt: Date;   reason: string }
  | { status: "refunded";   refundedAt: Date;    amount: number };

// 遷移イベント
type OrderEvent =
  | { type: "PAY";       transactionId: string }
  | { type: "SHIP";      trackingCode: string }
  | { type: "DELIVER" }
  | { type: "CANCEL";    reason: string }
  | { type: "REFUND";    amount: number };

// 遷移関数: 現在の状態とイベントから次の状態を計算
// 無効な遷移は Error または null を返す
function transition(
  state: OrderState,
  event: OrderEvent
): OrderState {
  const now = new Date();

  switch (state.status) {
    case "pending":
      if (event.type === "PAY") {
        return { status: "paid", paidAt: now, transactionId: event.transactionId };
      }
      if (event.type === "CANCEL") {
        return { status: "cancelled", cancelledAt: now, reason: event.reason };
      }
      break;

    case "paid":
      if (event.type === "SHIP") {
        return { status: "shipped", shippedAt: now, trackingCode: event.trackingCode };
      }
      if (event.type === "CANCEL") {
        return { status: "cancelled", cancelledAt: now, reason: event.reason };
      }
      break;

    case "shipped":
      if (event.type === "DELIVER") {
        return { status: "delivered", deliveredAt: now };
      }
      break;

    case "delivered":
      if (event.type === "REFUND") {
        return { status: "refunded", refundedAt: now, amount: event.amount };
      }
      break;

    case "cancelled":
    case "refunded":
      break; // 終端状態: どのイベントも無効
  }

  throw new Error(
    `無効な遷移: ${state.status} + ${event.type}`
  );
}

// ─── State Machine クラス ───
class Order {
  private state: OrderState;

  constructor(private readonly id: string) {
    this.state = { status: "pending", createdAt: new Date() };
  }

  dispatch(event: OrderEvent): void {
    this.state = transition(this.state, event);
  }

  getState(): Readonly<OrderState> {
    return this.state;
  }

  // 特定状態かどうかの型ガード
  isPaid(): this is { state: Extract<OrderState, { status: "paid" }> } {
    return this.state.status === "paid";
  }
}

// ─── 利用例 ───
const order = new Order("ORD-001");

// pending → paid
order.dispatch({ type: "PAY", transactionId: "TXN-123" });
console.log(order.getState().status); // "paid"

// paid → shipped
order.dispatch({ type: "SHIP", trackingCode: "YM-987654321" });

// 無効な遷移 → 実行時エラー
// order.dispatch({ type: "PAY", transactionId: "TXN-456" });
// → Error: 無効な遷移: shipped + PAY

State Machine の状態型はDiscriminated Union ガイドの応用です。さらに複雑な状態遷移には XState(TypeScript 対応の状態機械ライブラリ)の利用も検討してください。

よくある質問

QTypeScript でデザインパターンを実装する際にクラスと関数どちらが推奨されますか?

A一概にどちらとは言えませんが、近年の TypeScript ではクラスより関数・型・インターフェースを組み合わせる傾向があります。Strategy・Observer・Command などは関数型で実装するとシンプルになります。Singleton・Builder のような「状態やライフサイクルが重要なパターン」はクラスが向いています。React でも関数コンポーネント + Hooks が標準になったように、「必要な場合だけクラス」という選択が現代的です。

Q「デザインパターンは不要」という意見を聞きますが本当ですか?

Aパターンを目的化すること(過設計)は確かに問題です。しかし「命名と構造の共通語」としての価値は変わりません。「Strategy パターンで実装しました」と言えばチーム全員が設計意図を理解できます。また TypeScript + デザインパターンの組み合わせでは型がパターンの制約を強制するため、意図しない実装のずれをコンパイル時に防げます。「必要なときに使う」のが正しい姿勢です。

QTypedEventEmitter と RxJS の Observable はどちらを使えばよいですか?

Aシンプルなイベント通知には TypedEventEmitter で十分です。RxJS の Observable は「ストリームの変換・合成・バックプレッシャー制御」が必要な場合に有効で、Angular では標準的に使われます。React + Next.js ではオーバースペックになることが多く、まず TypedEventEmitter や Zustand・Jotai などの状態管理ライブラリで対応し、複雑な非同期ストリーム処理が必要になった時点で RxJS を検討するのがよいでしょう。

QBuilder パターンで型パラメータを使った必須フィールド強制は難しすぎませんか?

Aチームの TypeScript レベルによります。基本の Partial<Request> + build() 内での実行時チェックで十分なプロジェクトがほとんどです。型パラメータ版(TypedRequestBuilder<TSet>)は型レベルで呼び出し順序を強制したい場合に使います。公開ライブラリや社内フレームワークの設計では有効ですが、通常のアプリケーションコードには過設計になることがあります。

QState Machine の遷移を TypeScript で完全に型レベルで管理できますか?

Aできますが複雑になります。transition(state: Extract<OrderState, { status: "pending" }>, event: Extract<OrderEvent, { type: "PAY" }>): Extract<OrderState, { status: "paid" }>のようにオーバーロードで各遷移を型定義すると、無効な遷移がコンパイル時エラーになります。ただし状態数が多いと組み合わせ爆発するため、実務では実行時エラーで検出する方が現実的です。型レベルの State Machine が必要なら XState v5(TypeScript 対応)の利用を推奨します。

まとめ

パターン TypeScript での実装ポイント 主な用途
Singleton private コンストラクタ + static getInstance(). ジェネリクス版で汎用化 DB接続・設定・ロガー
Factory Method Discriminated Union + switch 網羅性チェック 種別によるオブジェクト生成
Abstract Factory インターフェースで Factory の「契約」を定義。テスト時に Mock と差し替え DI・テスト容易性
Builder メソッドチェーン(fluent API)。型パラメータで必須フィールドを強制 複雑なオブジェクト構築
Strategy 関数型 or インターフェースでアルゴリズムを分離。ジェネリクスで型安全に アルゴリズム差し替え・決済方法
Observer イベントマップ型でイベント名と payload を紐づけた TypedEventEmitter リアルタイム通知・コンポーネント間通信
Command execute()/undo() インターフェース + CommandHistory で Undo/Redo エディタ・トランザクション
State Machine Discriminated Union で状態型を定義。無効な遷移は Error で検出 注文管理・ワークフロー

デザインパターンは目的化すると過設計になります。「このコードは後で差し替えるか?」「チームが意図を理解できるか?」を基準に適切なパターンを選択してください。TypeScript のクラスの基礎はTypeScript クラス完全ガイドを、ジェネリクスの詳細はTypeScript ジェネリクス完全ガイドを参照してください。