10
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript時代におけるGoFデザインパターンの再考と、現代の代替手法10選

10
Posted at

対象読者

  • TypeScriptを実務で利用しており、よりシンプルで型安全な設計を模索している開発者
  • 過去にJavaやC++などでGoFデザインパターンを学んだ経験があり、現代のTypeScriptにおける最適な書き方やマッピングに悩んでいる方
  • クラスベースのオブジェクト指向設計から、関数や高度な型システムを活用したモダンなアーキテクチャへ移行したい方

はじめに

1994年に提唱されたGoF(Gang of Four)のデザインパターンは、当時のC++やJavaなどが抱えていた「オブジェクト指向言語の表現力の不足」を補うためのワークアラウンド(代替手段)という側面を強く持っていました。

現代のTypeScriptが備えているパラダイムや機能(第一級関数、強力な型システム、モジュールシステムなど)を取り入れれば、かつてGoFがクラスの継承や複雑なインターフェース群を使って解決していた問題の多くは、よりシンプルに実装可能になっています。

本記事では、あえて class を利用せずに、TypeScriptのモダンな機能を用いてGoFパターンを置き換える、現代的な10のアプローチを紹介します。

なお、本記事における「GoFパターンの現代的解釈」というテーマは、0h-n0氏の以下の記事から大きなインスピレーションを得ています。

元の記事では複数のモダン言語を対象に、GoFの全23パターンが「不要になった」「形を変えた」「今なお有用」の3つに分類・考察されています。本記事はそこから着想を得て、**TypeScriptの実務に特化した具体的なコード例(10パターン)**として深掘りし、独自の視点で再構成したものです。

なぜ「クラス」を使わなくなるのか

現代のTypeScript開発において、GoF的なクラスベースの設計が敬遠される主な理由は以下の通りです。

  • 状態と振る舞いの分離: 状態(データ)と振る舞い(関数)を分離し、コンポジションによって設計する方が、テストや予測が容易になるため。
  • this の問題: クラスメソッド特有の実行コンテキスト(this)喪失による予期せぬバグを防ぐため。
  • 型システムの進化: 直和型(Discriminated Unions)やUtility Typesの登場により、クラス階層を用いたポリモーフィズムの必要性が薄れたため。

TypeScriptにおけるGoFパターンの現代的代替手法10選

1. Strategy パターン → 第一級関数(高階関数)

実行時にアルゴリズムを切り替えるStrategyパターンは、インターフェースとクラス群を作らずとも、純粋関数を引数として渡すだけで完結します。

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

const regularDiscount: DiscountStrategy = (price) => price * 0.9;
const premiumDiscount: DiscountStrategy = (price) => price * 0.8;

// 関数を受け取って実行するだけ
const checkout = (price: number, applyDiscount: DiscountStrategy) => {
  return applyDiscount(price);
};

checkout(1000, premiumDiscount); // 800

2. Visitor パターン → 直和型と網羅性チェック

複雑な二重ディスパッチを要求するVisitorパターンは、直和型(Discriminated Unions)と switch 文、そして never 型によるコンパイラの網羅性チェックで安全かつシンプルに代替できます。

type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; sideLength: number };
type Shape = Circle | Square;

const calculateArea = (shape: Shape): number => {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.sideLength ** 2;
    default:
      // 新しいShapeが追加された際にコンパイルエラーを発生させる
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
};

3. Singleton パターン → ES Modules

グローバルな単一インスタンスの保証は、クラスの private constructor を使わずとも、ESモジュールのキャッシュ機構に委ねるのが標準です。

// database.ts
const connectionPool = new Map<string, any>();

export const dbConnect = (id: string) => {
  if (!connectionPool.has(id)) {
    connectionPool.set(id, { status: 'connected' });
  }
  return connectionPool.get(id);
};
// どこからインポートしても同じ状態が参照される

4. Builder パターン → オブジェクトスプレッドとUtility Types

複雑なオブジェクトの生成や一部更新は、Builderクラスを定義するのではなく、スプレッド構文と PartialOmit などの組み込み型を使います。

type User = { id: string; name: string; age: number; role: string };

const updateUser = (user: User, updates: Partial<Omit<User, 'id'>>): User => ({
  ...user,
  ...updates
});

5. Factory パターン → ファクトリ関数とクロージャ

new キーワードを避け、プレーンなオブジェクトやクロージャを返す関数を利用します。内部状態を完全にプライベートに保つことができます。

const createCounter = (initial: number = 0) => {
  let count = initial; // 外部からアクセス不可能な状態
  return {
    increment: () => ++count,
    get: () => count
  };
};

const counter = createCounter(10);

6. Command パターン → アクションオブジェクト (Fluxライク)

「何をするか」という命令を純粋なデータオブジェクトとして表現し、実際の処理から分離します。

type Action = 
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'REMOVE_TODO'; payload: number };

const dispatch = (action: Action) => {
  // Reducer等の処理へ委譲
};

7. Decorator パターン → 高階関数によるラップ

既存の関数の振る舞いを拡張する場合、クラスデコレータではなく、関数を引数に取り新しい関数を返すラッパー関数を利用します。

const withLogging = <T, U>(fn: (arg: T) => U) => {
  return (arg: T): U => {
    console.log(`Executing with:`, arg);
    return fn(arg);
  };
};

const add = (a: number) => a + 1;
const addWithLog = withLogging(add);

8. Iterator パターン → 非同期ジェネレータ

データコレクションの反復処理やストリーム処理は、Iteratorクラスを自作せず、言語標準のジェネレータ関数を利用します。

async function* fetchPages(urls: string[]) {
  for (const url of urls) {
    const res = await fetch(url);
    yield await res.json();
  }
}

9. Adapter パターン → 型レベルプログラミング (Mapped Types)

実行時のインターフェース変換(Adapter)を行う前に、TypeScriptの型システムで静的にインターフェースを適合させ、ランタイムのオーバーヘッドを削減します。

type ApiFormat = { user_id: number; user_name: string };

// キャメルケースへの変換を型レベルで定義
type FrontendFormat = {
  [K in keyof ApiFormat as K extends `${infer P1}_${infer P2}` ? `${P1}${Capitalize<P2>}` : K]: ApiFormat[K]
};

10. Template Method パターン → コールバック関数の注入

処理の骨組み(テンプレート)を定義し、一部のステップを差し替えるパターンも、クラスの継承ではなく関数へのコールバック渡しで実装します。

type DataProcessor = {
  parse: (raw: string) => any;
  validate: (data: any) => boolean;
};

// 骨組みとなる共通処理
const processWorkflow = (raw: string, processor: DataProcessor) => {
  const data = processor.parse(raw);
  if (processor.validate(data)) {
    console.log("Success:", data);
  }
};

よくある疑問:「クラスでまとめたほうがテストしやすくないか?」

クラスベースの設計から関数ベースの設計に移行する際、「状態とメソッドをクラスにカプセル化したほうが、モックの注入やテストのセットアップが容易ではないか」という疑問がよく生じます。OOPに慣れ親しんだ開発者であれば、これは非常に自然な視点です。

しかし、現代のTypeScriptにおいて関数ベース(特に純粋関数)のアプローチが主流になりつつある背景には、テストの複雑性を構造的に下げるという明確な理由があります。

1. 暗黙の状態(this)の排除による予測可能性

クラスのメソッドテストは「インスタンスの現在の状態」に依存するため、実行順序による副作用を考慮したセットアップが必要です。純粋関数であれば「入力」のみで「出力」が決まるため、事前状態のセットアップが不要で、個々のテストの独立性が完全に保たれます。

【クラスベースのテスト(状態に依存する例)】

class Counter {
  constructor(private count: number) {}
  increment() { this.count++; }
  getValue() { return this.count; }
}

test('Counter', () => {
  const counter = new Counter(10);
  counter.increment();
  counter.increment();
  // 内部状態がどう変化したか、メソッドの呼び出し順序に依存する
  expect(counter.getValue()).toBe(12);
});

【関数ベースのテスト(状態に依存しない純粋関数)】

const increment = (count: number) => count + 1;

test('increment', () => {
  // 入力と出力が1対1に対応し、他のテストケースに影響を与えない
  expect(increment(10)).toBe(11);
  expect(increment(11)).toBe(12);
});

2. モックと依存性注入(DI)の単純化

クラスではコンストラクタ経由でモックインスタンスを注入する必要があります。そのためには、インターフェースを満たす巨大なダミーオブジェクトを作るか、モックライブラリのセットアップが必要です。
一方、関数ベースでは依存する処理を単なる「関数」として引数で受け取るため、テスト時はごくシンプルな関数を一つ渡すだけで済みます。

【クラスベースのDIとモック】

interface UserRepository {
  findById(id: string): Promise<User>;
}

class UserService {
  constructor(private repo: UserRepository) {}
  async getUserData(id: string) { return await this.repo.findById(id); }
}

test('UserService', async () => {
  // インターフェースを満たすオブジェクトをわざわざ用意する手間がかかる
  const mockRepo: UserRepository = {
    findById: async (id) => ({ id, name: 'Test User' })
  };
  const service = new UserService(mockRepo);
  expect(await service.getUserData('1')).toEqual({ id: '1', name: 'Test User' });
});

【関数ベースのDIとモック】

const getUserData = async (id: string, fetcher: (id: string) => Promise<User>) => {
  return await fetcher(id);
};

test('getUserData', async () => {
  // テスト時はその場で作ったシンプルな関数(クロージャ)を渡すだけ
  const mockFetcher = async (id: string) => ({ id, name: 'Test User' });
  expect(await getUserData('1', mockFetcher)).toEqual({ id: '1', name: 'Test User' });
});

3. 境界値テストと型システムの恩恵

直和型とswitch文を用いた関数は、TypeScriptのコンパイラが「状態の網羅性」を静的に保証してくれます。そのため、「想定外のインスタンス状態(例:初期化されていないプロパティへのアクセス)」に対する防御的テストを書くコストが減り、コアなビジネスロジックの検証により多くの時間を割くことができます。

おわりに

実装手段としてのGoFパターンの多くは、TypeScriptの強力な型システムと関数型プログラミングのアプローチによって、よりシンプルで安全なコードに置き換えられます。

一方で、ソフトウェア設計における「問題のカタログ」や、開発者間の「共通語彙」としてのGoFの価値は依然として残っています。パターンの「実装(How)」を鵜呑みにするのではなく、その背後にある「目的(Why)」を理解し、現代の言語機能に合わせた最適な表現を選択することが重要です。

10
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?