TypeScriptで学ぶデザインパターン入門|実務で使える8つのパターン

kento_morota 34分で読めます

デザインパターンは、ソフトウェア設計における「定番の解決策」です。GoF(Gang of Four)が体系化した23のパターンは有名ですが、すべてを覚える必要はありません。TypeScriptを使った現代のWeb開発では、特に頻出するパターンがあります。

本記事では、実務で本当に役立つ8つのデザインパターンを、TypeScriptのコード例とユースケースとともに解説します。

デザインパターンを学ぶメリット

デザインパターンを学ぶことで、以下のメリットが得られます。

設計の語彙が増える:「ここはStrategyパターンで」と言えば、チームメンバーが設計意図を即座に理解できます。コミュニケーションコストが大幅に下がります。

変更に強いコードが書ける:パターンは「変化する部分」と「変化しない部分」を分離する技術です。将来の仕様変更に柔軟に対応できる構造が自然と身につきます。

コードの再利用性が向上する:よく整理された構造は、別のプロジェクトや別の場面でも流用しやすくなります。

TypeScriptでデザインパターンを実装する利点

TypeScriptは、インターフェース、ジェネリック、アクセス修飾子(private/protected/public)を備えており、デザインパターンの実装に非常に適しています。Javaのような冗長さがなく、JavaScriptの柔軟性を活かしながらも型安全なパターンを記述できます。

Singletonパターン:インスタンスを1つに制限する

Singletonパターンは、クラスのインスタンスがアプリケーション全体で1つだけであることを保証するパターンです。設定管理やデータベース接続プールなどに使われます。

基本実装

class DatabaseConnection {
  private static instance: DatabaseConnection;
  private connection: any;

  // コンストラクタをprivateにして外部からのnewを禁止
  private constructor(private connectionString: string) {
    this.connection = this.connect(connectionString);
  }

  static getInstance(connectionString?: string): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      if (!connectionString) {
        throw new Error("初回はconnectionStringが必要です");
      }
      DatabaseConnection.instance = new DatabaseConnection(connectionString);
    }
    return DatabaseConnection.instance;
  }

  private connect(connectionString: string) {
    console.log(`Connecting to ${connectionString}`);
    return { /* connection object */ };
  }

  query(sql: string): any {
    console.log(`Executing: ${sql}`);
    return [];
  }
}

// 使用例
const db1 = DatabaseConnection.getInstance("postgresql://localhost:5432/mydb");
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true - 同じインスタンス

モジュールシングルトン(推奨)

TypeScript/JavaScriptではモジュールスコープ自体がシングルトンとして機能するため、クラスベースよりもシンプルに実装できます。

// config.ts
interface AppConfig {
  apiBaseUrl: string;
  apiKey: string;
  debug: boolean;
  maxRetries: number;
}

let config: AppConfig | null = null;

export function initConfig(overrides: Partial<AppConfig> = {}): AppConfig {
  config = {
    apiBaseUrl: process.env.API_BASE_URL || "http://localhost:3000",
    apiKey: process.env.API_KEY || "",
    debug: process.env.NODE_ENV === "development",
    maxRetries: 3,
    ...overrides,
  };
  return config;
}

export function getConfig(): AppConfig {
  if (!config) {
    throw new Error("Config not initialized. Call initConfig() first.");
  }
  return Object.freeze({ ...config }); // イミュータブルなコピーを返す
}

Factoryパターン:オブジェクト生成を抽象化する

Factoryパターンは、オブジェクトの生成ロジックをカプセル化し、呼び出し側が具体的なクラスを知らなくてもインスタンスを作れるようにするパターンです。

通知システムでのFactory

// 通知のインターフェース
interface Notification {
  send(to: string, message: string): Promise<void>;
}

// 具体的な通知クラス
class EmailNotification implements Notification {
  async send(to: string, message: string): Promise<void> {
    console.log(`Email to ${to}: ${message}`);
    // SMTP送信処理
  }
}

class SlackNotification implements Notification {
  constructor(private webhookUrl: string) {}

  async send(to: string, message: string): Promise<void> {
    console.log(`Slack to ${to}: ${message}`);
    // Webhook送信処理
  }
}

class SmsNotification implements Notification {
  async send(to: string, message: string): Promise<void> {
    console.log(`SMS to ${to}: ${message}`);
    // SMS送信処理
  }
}

// Factory
type NotificationType = "email" | "slack" | "sms";

class NotificationFactory {
  private static creators: Record<NotificationType, () => Notification> = {
    email: () => new EmailNotification(),
    slack: () => new SlackNotification(process.env.SLACK_WEBHOOK_URL || ""),
    sms: () => new SmsNotification(),
  };

  static create(type: NotificationType): Notification {
    const creator = this.creators[type];
    if (!creator) {
      throw new Error(`Unknown notification type: ${type}`);
    }
    return creator();
  }

  // 新しい種類の通知を動的に登録
  static register(type: string, creator: () => Notification) {
    this.creators[type as NotificationType] = creator;
  }
}

// 使用例
async function notifyUser(type: NotificationType, to: string, message: string) {
  const notification = NotificationFactory.create(type);
  await notification.send(to, message);
}

await notifyUser("email", "user@example.com", "注文が確定しました");
await notifyUser("slack", "#general", "デプロイが完了しました");

Observerパターン:イベント駆動の設計

Observerパターンは、あるオブジェクト(Subject)の状態変化を、複数のオブジェクト(Observer)に自動的に通知する仕組みです。イベントエミッタやPub/Subの基盤となるパターンです。

型安全なEventEmitter

// イベントマップの型定義
interface EventMap {
  userCreated: { id: number; name: string; email: string };
  userUpdated: { id: number; changes: Record<string, unknown> };
  userDeleted: { id: number };
  orderPlaced: { orderId: string; userId: number; total: number };
}

// 型安全なEventEmitter
class TypedEventEmitter<T extends Record<string, unknown>> {
  private listeners: {
    [K in keyof T]?: Array<(payload: T[K]) => void>;
  } = {};

  on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): () => void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);

    // unsubscribe関数を返す
    return () => {
      this.listeners[event] = this.listeners[event]!.filter((l) => l !== listener);
    };
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    const eventListeners = this.listeners[event];
    if (eventListeners) {
      eventListeners.forEach((listener) => listener(payload));
    }
  }
}

// 使用例
const eventBus = new TypedEventEmitter<EventMap>();

// リスナー登録(型安全!)
const unsubscribe = eventBus.on("userCreated", (payload) => {
  // payload は { id: number; name: string; email: string } と推論される
  console.log(`New user: ${payload.name} (${payload.email})`);
});

eventBus.on("orderPlaced", (payload) => {
  // payload は { orderId: string; userId: number; total: number } と推論される
  console.log(`Order ${payload.orderId}: ¥${payload.total}`);
});

// イベント発火(型安全!)
eventBus.emit("userCreated", { id: 1, name: "田中太郎", email: "tanaka@example.com" });
eventBus.emit("orderPlaced", { orderId: "ORD-001", userId: 1, total: 5000 });

// 購読解除
unsubscribe();

Strategyパターン:アルゴリズムを切り替える

Strategyパターンは、アルゴリズムをクラスとして独立させ、実行時に切り替え可能にするパターンです。条件分岐の肥大化を防ぎ、新しいアルゴリズムの追加を容易にします。

料金計算でのStrategy

// 料金計算の戦略インターフェース
interface PricingStrategy {
  calculate(basePrice: number, quantity: number): number;
  getName(): string;
}

// 通常料金
class RegularPricing implements PricingStrategy {
  calculate(basePrice: number, quantity: number): number {
    return basePrice * quantity;
  }
  getName(): string {
    return "通常料金";
  }
}

// 会員割引
class MemberPricing implements PricingStrategy {
  constructor(private discountRate: number = 0.1) {}

  calculate(basePrice: number, quantity: number): number {
    const subtotal = basePrice * quantity;
    return Math.floor(subtotal * (1 - this.discountRate));
  }
  getName(): string {
    return `会員割引(${this.discountRate * 100}%OFF)`;
  }
}

// 数量割引
class BulkPricing implements PricingStrategy {
  private tiers = [
    { minQty: 100, discount: 0.2 },
    { minQty: 50, discount: 0.15 },
    { minQty: 10, discount: 0.1 },
    { minQty: 1, discount: 0 },
  ];

  calculate(basePrice: number, quantity: number): number {
    const tier = this.tiers.find((t) => quantity >= t.minQty)!;
    const subtotal = basePrice * quantity;
    return Math.floor(subtotal * (1 - tier.discount));
  }
  getName(): string {
    return "数量割引";
  }
}

// コンテキスト
class PriceCalculator {
  private strategy: PricingStrategy;

  constructor(strategy: PricingStrategy = new RegularPricing()) {
    this.strategy = strategy;
  }

  setStrategy(strategy: PricingStrategy): void {
    this.strategy = strategy;
  }

  calculateTotal(basePrice: number, quantity: number): {
    strategyName: string;
    total: number;
  } {
    return {
      strategyName: this.strategy.getName(),
      total: this.strategy.calculate(basePrice, quantity),
    };
  }
}

// 使用例
const calculator = new PriceCalculator();

// 通常料金
console.log(calculator.calculateTotal(1000, 5));
// { strategyName: "通常料金", total: 5000 }

// 会員割引に切り替え
calculator.setStrategy(new MemberPricing(0.15));
console.log(calculator.calculateTotal(1000, 5));
// { strategyName: "会員割引(15%OFF)", total: 4250 }

// 数量割引に切り替え
calculator.setStrategy(new BulkPricing());
console.log(calculator.calculateTotal(1000, 50));
// { strategyName: "数量割引", total: 42500 }

Repositoryパターン:データアクセスを抽象化する

Repositoryパターンは、データの永続化ロジックをビジネスロジックから分離するパターンです。データベースの種類やORMの変更がビジネスロジックに影響しないようにします。

汎用リポジトリの実装

// エンティティの基底型
interface Entity {
  id: number;
}

// リポジトリインターフェース
interface Repository<T extends Entity> {
  findById(id: number): Promise<T | null>;
  findAll(filter?: Partial<T>): Promise<T[]>;
  create(data: Omit<T, "id">): Promise<T>;
  update(id: number, data: Partial<Omit<T, "id">>): Promise<T>;
  delete(id: number): Promise<void>;
}

// ユーザーエンティティ
interface User extends Entity {
  name: string;
  email: string;
  role: string;
}

// Prisma実装
class PrismaUserRepository implements Repository<User> {
  constructor(private prisma: any) {}

  async findById(id: number): Promise<User | null> {
    return this.prisma.user.findUnique({ where: { id } });
  }

  async findAll(filter?: Partial<User>): Promise<User[]> {
    return this.prisma.user.findMany({ where: filter });
  }

  async create(data: Omit<User, "id">): Promise<User> {
    return this.prisma.user.create({ data });
  }

  async update(id: number, data: Partial<Omit<User, "id">>): Promise<User> {
    return this.prisma.user.update({ where: { id }, data });
  }

  async delete(id: number): Promise<void> {
    await this.prisma.user.delete({ where: { id } });
  }
}

// テスト用のインメモリ実装
class InMemoryUserRepository implements Repository<User> {
  private users: User[] = [];
  private nextId = 1;

  async findById(id: number): Promise<User | null> {
    return this.users.find((u) => u.id === id) || null;
  }

  async findAll(filter?: Partial<User>): Promise<User[]> {
    if (!filter) return [...this.users];
    return this.users.filter((u) =>
      Object.entries(filter).every(([key, value]) => u[key as keyof User] === value)
    );
  }

  async create(data: Omit<User, "id">): Promise<User> {
    const user = { ...data, id: this.nextId++ };
    this.users.push(user);
    return user;
  }

  async update(id: number, data: Partial<Omit<User, "id">>): Promise<User> {
    const index = this.users.findIndex((u) => u.id === id);
    if (index === -1) throw new Error("User not found");
    this.users[index] = { ...this.users[index], ...data };
    return this.users[index];
  }

  async delete(id: number): Promise<void> {
    this.users = this.users.filter((u) => u.id !== id);
  }
}

// ビジネスロジック(リポジトリの実装に依存しない)
class UserService {
  constructor(private userRepo: Repository<User>) {}

  async getActiveAdmins(): Promise<User[]> {
    return this.userRepo.findAll({ role: "admin" });
  }
}

Builder・Adapter・Decoratorパターン

Builderパターン:複雑なオブジェクト構築

interface QueryConfig {
  table: string;
  select: string[];
  where: Array<{ field: string; operator: string; value: unknown }>;
  orderBy: Array<{ field: string; direction: "ASC" | "DESC" }>;
  limit?: number;
  offset?: number;
}

class QueryBuilder {
  private config: QueryConfig;

  constructor(table: string) {
    this.config = { table, select: ["*"], where: [], orderBy: [] };
  }

  select(...fields: string[]): this {
    this.config.select = fields;
    return this;
  }

  where(field: string, operator: string, value: unknown): this {
    this.config.where.push({ field, operator, value });
    return this;
  }

  orderBy(field: string, direction: "ASC" | "DESC" = "ASC"): this {
    this.config.orderBy.push({ field, direction });
    return this;
  }

  limit(count: number): this {
    this.config.limit = count;
    return this;
  }

  offset(count: number): this {
    this.config.offset = count;
    return this;
  }

  build(): string {
    let sql = `SELECT ${this.config.select.join(", ")} FROM ${this.config.table}`;

    if (this.config.where.length > 0) {
      const conditions = this.config.where
        .map((w) => `${w.field} ${w.operator} '${w.value}'`)
        .join(" AND ");
      sql += ` WHERE ${conditions}`;
    }

    if (this.config.orderBy.length > 0) {
      const orders = this.config.orderBy
        .map((o) => `${o.field} ${o.direction}`)
        .join(", ");
      sql += ` ORDER BY ${orders}`;
    }

    if (this.config.limit !== undefined) sql += ` LIMIT ${this.config.limit}`;
    if (this.config.offset !== undefined) sql += ` OFFSET ${this.config.offset}`;

    return sql;
  }
}

// 使用例:メソッドチェーンで直感的に構築
const query = new QueryBuilder("users")
  .select("id", "name", "email")
  .where("role", "=", "admin")
  .where("age", ">=", 20)
  .orderBy("name", "ASC")
  .limit(10)
  .offset(0)
  .build();

// SELECT id, name, email FROM users WHERE role = 'admin' AND age >= '20' ORDER BY name ASC LIMIT 10 OFFSET 0

Adapterパターン:異なるインターフェースを橋渡しする

// 統一的なログインターフェース
interface Logger {
  info(message: string, meta?: Record<string, unknown>): void;
  warn(message: string, meta?: Record<string, unknown>): void;
  error(message: string, meta?: Record<string, unknown>): void;
}

// console用アダプター
class ConsoleLoggerAdapter implements Logger {
  info(message: string, meta?: Record<string, unknown>): void {
    console.log(`[INFO] ${message}`, meta || "");
  }
  warn(message: string, meta?: Record<string, unknown>): void {
    console.warn(`[WARN] ${message}`, meta || "");
  }
  error(message: string, meta?: Record<string, unknown>): void {
    console.error(`[ERROR] ${message}`, meta || "");
  }
}

// 外部ログサービス用アダプター
class ExternalLogServiceAdapter implements Logger {
  constructor(private apiKey: string, private endpoint: string) {}

  private async sendLog(level: string, message: string, meta?: Record<string, unknown>) {
    await fetch(this.endpoint, {
      method: "POST",
      headers: { "Authorization": `Bearer ${this.apiKey}` },
      body: JSON.stringify({ level, message, meta, timestamp: new Date().toISOString() }),
    });
  }

  info(message: string, meta?: Record<string, unknown>): void {
    this.sendLog("info", message, meta);
  }
  warn(message: string, meta?: Record<string, unknown>): void {
    this.sendLog("warn", message, meta);
  }
  error(message: string, meta?: Record<string, unknown>): void {
    this.sendLog("error", message, meta);
  }
}

// 環境に応じてアダプターを切り替え
const logger: Logger =
  process.env.NODE_ENV === "production"
    ? new ExternalLogServiceAdapter(process.env.LOG_API_KEY!, process.env.LOG_ENDPOINT!)
    : new ConsoleLoggerAdapter();

Decoratorパターン:機能を動的に追加する

// 基本のAPIクライアント
interface HttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, body: unknown): Promise<T>;
}

class BaseHttpClient implements HttpClient {
  async get<T>(url: string): Promise<T> {
    const res = await fetch(url);
    return res.json();
  }
  async post<T>(url: string, body: unknown): Promise<T> {
    const res = await fetch(url, { method: "POST", body: JSON.stringify(body) });
    return res.json();
  }
}

// ログ機能を追加するデコレーター
class LoggingHttpClient implements HttpClient {
  constructor(private client: HttpClient, private logger: Logger) {}

  async get<T>(url: string): Promise<T> {
    this.logger.info(`GET ${url}`);
    const start = Date.now();
    const result = await this.client.get<T>(url);
    this.logger.info(`GET ${url} completed in ${Date.now() - start}ms`);
    return result;
  }

  async post<T>(url: string, body: unknown): Promise<T> {
    this.logger.info(`POST ${url}`);
    const result = await this.client.post<T>(url, body);
    return result;
  }
}

// リトライ機能を追加するデコレーター
class RetryHttpClient implements HttpClient {
  constructor(private client: HttpClient, private maxRetries: number = 3) {}

  async get<T>(url: string): Promise<T> {
    for (let i = 0; i < this.maxRetries; i++) {
      try {
        return await this.client.get<T>(url);
      } catch (e) {
        if (i === this.maxRetries - 1) throw e;
        await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
      }
    }
    throw new Error("Unreachable");
  }

  async post<T>(url: string, body: unknown): Promise<T> {
    return this.client.post<T>(url, body);
  }
}

// デコレーターを積み重ねて機能を追加
const client: HttpClient = new RetryHttpClient(
  new LoggingHttpClient(new BaseHttpClient(), logger),
  3
);

デザインパターンを適用する際の注意点

過度な抽象化を避ける

デザインパターンは問題を解決するための手段であり、目的ではありません。小規模なプロジェクトや変更頻度の低い部分に無理にパターンを適用すると、かえってコードが複雑になります。

パターンを適用すべきサイン:

同じような条件分岐が複数箇所に散在している。将来的に同種のバリエーションが増える可能性が高い。テストが書きにくい構造になっている。

パターンが不要なサイン:

バリエーションが2つ以下で、今後も増える見込みがない。一度書いたら変更されないコードである。チームメンバーがパターンを理解していない。

関数型アプローチとの使い分け

TypeScriptでは、クラスベースのデザインパターンだけでなく、関数型のアプローチも有効です。Strategyパターンは関数の引数として渡すだけで実現でき、Factoryは単純な関数で十分な場合も多いです。

// クラスベースのStrategy
class UserService {
  constructor(private pricingStrategy: PricingStrategy) {}
}

// 関数型のStrategy(シンプルなケースではこちらで十分)
type PricingFn = (basePrice: number, quantity: number) => number;

const regularPricing: PricingFn = (price, qty) => price * qty;
const memberPricing: PricingFn = (price, qty) => Math.floor(price * qty * 0.9);

function calculateTotal(price: number, qty: number, strategy: PricingFn): number {
  return strategy(price, qty);
}

まとめ

本記事で紹介した8つのデザインパターンは、TypeScriptの実務で特に頻出するものです。

Singleton:設定管理やDB接続など、インスタンスを1つに制限したい場合に使用します。TypeScriptではモジュールスコープで代替可能です。

Factory:条件に応じてオブジェクトを生成する場合に使用します。通知やレポートの種類分岐に有効です。

Observer:イベント駆動の仕組みを作る場合に使用します。型安全なEventEmitterとして実装できます。

Strategy:アルゴリズムを実行時に切り替える場合に使用します。料金計算やソートなどに有効です。

Repository:データアクセスを抽象化し、ビジネスロジックとデータ層を分離します。

Builder:複雑なオブジェクトをステップバイステップで構築します。クエリビルダーやフォームビルダーに有効です。

Adapter:異なるインターフェースを統一します。外部サービスの切り替えに有効です。

Decorator:既存のオブジェクトに動的に機能を追加します。ログやリトライなどの横断的関心事に有効です。

まずは自分のプロジェクトで「同じようなif-elseが増えてきた」「テストが書きにくい」と感じた部分から、適切なパターンを適用してみてください。

#TypeScript#デザインパターン
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう