T-CREATOR

<div />

TypeScriptでクラスを設計する 継承 抽象クラス ミックスインの使い分けと判断基準

2026年1月19日
TypeScriptでクラスを設計する 継承 抽象クラス ミックスインの使い分けと判断基準

TypeScript でクラス設計に取り組む際、継承・抽象クラス・ミックスインのどれを選ぶかは、拡張性と保守性を左右する重要な判断です。本記事では、静的型付けを活かした設計パターンの本質的な違いと、実務での判断基準を整理します。

継承・抽象クラス・ミックスインの比較

観点継承抽象クラスミックスイン
主な用途IS-A 関係の表現テンプレート提供機能の横断的付与
実装の共有基底クラスから継承部分実装を提供関数で機能を注入
多重継承不可(単一継承のみ)不可(単一継承のみ)可能(複数組み合わせ)
型安全性高い高い高い(静的ミックスイン)
結合度強い中程度弱い
適用場面階層的な関係性共通処理の強制独立した機能の合成

それぞれの詳細は後述します。

検証環境

  • OS: macOS Sequoia 15.2
  • Node.js: 22.13.0
  • TypeScript: 5.9.3
  • 主要パッケージ:
    • N/A
  • 検証日: 2026 年 01 月 19 日

TypeScript のクラス設計が複雑化する背景

この章では、なぜクラス設計の選択が難しくなるのかを整理します。

TypeScript は JavaScript のスーパーセットとして、静的型付けによる型安全性とオブジェクト指向のクラス構文を提供しています。しかし、JavaScript が本来プロトタイプベースの言語であることから、クラス設計においていくつかの制約が生じます。

単一継承の制約は、その代表的な例です。Java や C# のようなクラスベースの言語でも多重継承は避けられる傾向にありますが、TypeScript(JavaScript)では構文レベルで単一継承しかサポートされていません。

mermaidflowchart TB
  BaseClass["基底クラス"]
  DerivedA["派生クラス A"]
  DerivedB["派生クラス B"]

  BaseClass --> DerivedA
  BaseClass --> DerivedB

  subgraph constraint["単一継承の制約"]
    MultiBase1["クラス X"]
    MultiBase2["クラス Y"]
    ChildClass["子クラス"]
    MultiBase1 -.->|"不可"| ChildClass
    MultiBase2 -.->|"不可"| ChildClass
  end

上図は、TypeScript では1つのクラスからしか継承できない制約を示しています。

実務では、以下のような場面でクラス設計の選択に迷うことが多くなります。

  • ログ機能やキャッシュ機能を複数のクラスに横断的に付与したい
  • 共通のビジネスロジックを持つが、継承関係にない複数のエンティティがある
  • インターフェースだけでは実装の共有ができず、コードが重複する

つまずきやすい点:継承で解決しようとすると、本来無関係なクラス間に不自然な親子関係を作ってしまうことがあります。

継承だけに頼る設計が引き起こす課題

この章では、継承を多用した設計で実際に起きた問題を取り上げます。

深すぎる継承階層の問題

業務で問題になったケースとして、以下のような継承階層がありました。

typescript// 実際に問題になった継承階層の例
class BaseEntity {}
class AuditableEntity extends BaseEntity {}
class SoftDeletableEntity extends AuditableEntity {}
class VersionedEntity extends SoftDeletableEntity {}
class UserEntity extends VersionedEntity {}

この設計では、以下の問題が発生しました。

  1. 基底クラスの変更が全派生クラスに波及AuditableEntity のログ形式を変更したところ、意図しない箇所でテストが失敗
  2. 機能の選択的利用が不可能:バージョン管理が不要なエンティティでも VersionedEntity の機能を継承してしまう
  3. テストの困難さ:各階層のモック作成が複雑化

検証の結果、このような継承階層は3階層を超えると保守性が著しく低下することがわかりました。

フラジャイルベースクラス問題

基底クラスの実装変更が派生クラスの動作を壊す問題は、継承設計において深刻な課題です。

typescript// フラジャイルベースクラス問題の例
class Counter {
  protected count = 0;

  increment(): void {
    this.count++;
  }

  addMultiple(times: number): void {
    for (let i = 0; i < times; i++) {
      this.increment();
    }
  }
}

class LoggingCounter extends Counter {
  private logs: string[] = [];

  increment(): void {
    super.increment();
    this.logs.push(`Count: ${this.count}`);
  }
}

上記のコードでは、addMultiple(3) を呼ぶと increment が3回呼ばれ、ログも3件記録されます。しかし、基底クラスの addMultiple をパフォーマンス改善のため以下のように変更すると、動作が変わってしまいます。

typescript// 基底クラスの実装変更(意図せず派生クラスの動作を変更)
addMultiple(times: number): void {
  this.count += times; // increment() を呼ばなくなった
}

実際に試したところ、この変更後は LoggingCounter のログが記録されなくなりました。

継承・抽象クラス・インターフェースの適切な選択

この章では、各パターンの特性を踏まえた判断基準を示します。

インターフェースを選ぶ場面

インターフェースは、実装を含まない純粋な「契約」を定義します。型エイリアスと似ていますが、クラスの実装を強制できる点が異なります。

typescript// インターフェースによる契約定義
interface PaymentProcessor {
  processPayment(amount: number): Promise<PaymentResult>;
  validateData(data: PaymentData): boolean;
  calculateFee(amount: number): number;
}

interface PaymentResult {
  transactionId: string;
  status: "success" | "failed" | "pending";
  timestamp: Date;
}

interface PaymentData {
  method: string;
  details: Record<string, unknown>;
}

インターフェースを選ぶべき場面は以下です。

  • 複数の実装が存在し、それらを統一的に扱いたい
  • 依存性の注入(DI)パターンを適用したい
  • 実装の詳細を隠蔽し、契約のみを公開したい
typescript// インターフェースを活用した依存性注入
class PaymentService {
  constructor(private processor: PaymentProcessor) {}

  async handlePayment(
    amount: number,
    data: PaymentData,
  ): Promise<PaymentResult> {
    if (!this.processor.validateData(data)) {
      throw new Error("無効な支払いデータです");
    }
    return this.processor.processPayment(amount);
  }
}

// 実装の差し替えが容易
const service = new PaymentService(new CreditCardProcessor());

抽象クラスを選ぶ場面

抽象クラスは、共通の実装と抽象メソッドを組み合わせたテンプレートを提供します。

typescript// 抽象クラスによるテンプレートメソッドパターン
abstract class DatabaseConnection {
  protected isConnected = false;

  // 共通の実装
  async connect(): Promise<void> {
    if (this.isConnected) return;
    await this.establishConnection();
    this.isConnected = true;
  }

  // テンプレートメソッド
  async executeTransaction<T>(operation: () => Promise<T>): Promise<T> {
    await this.connect();
    try {
      await this.beginTransaction();
      const result = await operation();
      await this.commitTransaction();
      return result;
    } catch (error) {
      await this.rollbackTransaction();
      throw error;
    }
  }

  // 派生クラスで実装を強制
  protected abstract establishConnection(): Promise<void>;
  protected abstract beginTransaction(): Promise<void>;
  protected abstract commitTransaction(): Promise<void>;
  protected abstract rollbackTransaction(): Promise<void>;
}

抽象クラスを選ぶべき場面は以下です。

  • 共通の処理フローがあり、一部だけ派生クラスで実装させたい
  • コンストラクタで初期化処理を共通化したい
  • protected メンバーを派生クラスに公開したい

つまずきやすい点:抽象クラスとインターフェースの両方が使える場面では、まずインターフェースを検討してください。抽象クラスは継承階層を固定してしまうため、将来の拡張性を制限する可能性があります。

インターフェースと抽象クラスの判断基準

観点インターフェース抽象クラス
実装の共有不可可能
多重実装可能不可
コンストラクタ定義不可定義可能
アクセス修飾子public のみ全て使用可能
適用場面契約の定義、DIテンプレート提供

ミックスインによる機能の横断的付与

この章では、継承の制約を超えるミックスインパターンを解説します。

ミックスインの基本構造

ミックスインは、クラスに機能を「混ぜ込む」パターンです。TypeScript では関数を使って型安全に実装できます。

typescript// ミックスイン用の型定義
type Constructor<T = {}> = new (...args: any[]) => T;

// ログ機能のミックスイン
function WithLogging<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    private logs: string[] = [];

    log(message: string): void {
      const entry = `[${new Date().toISOString()}] ${message}`;
      this.logs.push(entry);
      console.log(entry);
    }

    getLogs(): readonly string[] {
      return this.logs;
    }
  };
}

// キャッシュ機能のミックスイン
function WithCaching<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    private cache = new Map<string, { data: unknown; expiry: number }>();

    getCached<T>(key: string): T | null {
      const cached = this.cache.get(key);
      if (!cached || Date.now() > cached.expiry) {
        this.cache.delete(key);
        return null;
      }
      return cached.data as T;
    }

    setCached(key: string, data: unknown, ttlMs = 300000): void {
      this.cache.set(key, { data, expiry: Date.now() + ttlMs });
    }
  };
}

ミックスインの組み合わせ

複数のミックスインを組み合わせることで、継承では実現できない柔軟な機能合成が可能です。

typescript// 基底クラス
class BaseService {
  constructor(public readonly name: string) {}
}

// 複数のミックスインを組み合わせ
class UserService extends WithCaching(WithLogging(BaseService)) {
  constructor() {
    super("UserService");
  }

  async getUser(id: string): Promise<User | null> {
    this.log(`ユーザー取得開始: ${id}`);

    const cached = this.getCached<User>(`user:${id}`);
    if (cached) {
      this.log("キャッシュから取得");
      return cached;
    }

    // データベースから取得(模擬)
    const user = await this.fetchFromDatabase(id);
    if (user) {
      this.setCached(`user:${id}`, user);
      this.log("データベースから取得してキャッシュに保存");
    }

    return user;
  }

  private async fetchFromDatabase(id: string): Promise<User | null> {
    return { id, name: `User ${id}`, email: `user${id}@example.com` };
  }
}

interface User {
  id: string;
  name: string;
  email: string;
}

動作確認済みのコードです。UserServiceloggetLogsgetCachedsetCached の全メソッドを利用できます。

静的ミックスインと動的ミックスインの違い

観点静的ミックスイン動的ミックスイン
決定タイミングコンパイル時実行時
型安全性完全部分的(any が必要)
パフォーマンス最適化可能オーバーヘッドあり
推奨度推奨特殊な要件時のみ

実務での推奨は静的ミックスインです。動的ミックスインは型安全性を損なうため、プラグインシステムなど特別な要件がある場合に限定してください。

SOLID 原則に基づく設計判断

この章では、設計原則を適用した具体的な判断フローを示します。

単一責任原則(SRP)とクラス分割

1つのクラスが複数の責任を持つと、変更理由が増え保守性が低下します。

typescript// SRP 違反の例
class BadUserManager {
  private users: Map<string, User> = new Map();

  addUser(user: User): void {
    this.users.set(user.id, user);
  }

  // メール送信の責任も持っている
  sendWelcomeEmail(userId: string): void {
    const user = this.users.get(userId);
    console.log(`Welcome email sent to ${user?.email}`);
  }

  // ログ記録の責任も持っている
  logUserAction(userId: string, action: string): void {
    console.log(`User ${userId}: ${action}`);
  }
}

SRP に従って責任を分離した設計は以下です。

typescript// SRP に従った設計
class UserRepository {
  private users: Map<string, User> = new Map();

  save(user: User): void {
    this.users.set(user.id, user);
  }

  findById(id: string): User | undefined {
    return this.users.get(id);
  }
}

class EmailService {
  sendWelcomeEmail(email: string): void {
    console.log(`Welcome email sent to ${email}`);
  }
}

class UserLogger {
  logAction(userId: string, action: string): void {
    console.log(`User ${userId}: ${action}`);
  }
}

開放閉鎖原則(OCP)と抽象クラス

拡張に対して開いており、修正に対して閉じている設計を実現するには、抽象クラスが有効です。

typescript// OCP に従った設計
abstract class NotificationSender {
  // 変更に対して閉じている部分
  async send(recipient: string, message: string): Promise<void> {
    const validated = this.validateRecipient(recipient);
    if (!validated) {
      throw new Error("無効な送信先です");
    }
    await this.doSend(recipient, message);
    await this.logSent(recipient);
  }

  // 拡張に対して開いている部分
  protected abstract validateRecipient(recipient: string): boolean;
  protected abstract doSend(recipient: string, message: string): Promise<void>;

  private async logSent(recipient: string): Promise<void> {
    console.log(`Notification sent to ${recipient}`);
  }
}

// 新しい通知手段を追加(拡張)
class EmailNotificationSender extends NotificationSender {
  protected validateRecipient(recipient: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient);
  }

  protected async doSend(recipient: string, message: string): Promise<void> {
    console.log(`Email to ${recipient}: ${message}`);
  }
}

class SlackNotificationSender extends NotificationSender {
  protected validateRecipient(recipient: string): boolean {
    return recipient.startsWith("@") || recipient.startsWith("#");
  }

  protected async doSend(recipient: string, message: string): Promise<void> {
    console.log(`Slack to ${recipient}: ${message}`);
  }
}
mermaidflowchart LR
  Abstract["NotificationSender<br/>(抽象クラス)"]
  Email["EmailNotificationSender"]
  Slack["SlackNotificationSender"]
  SMS["SMSNotificationSender<br/>(将来追加可能)"]

  Abstract --> Email
  Abstract --> Slack
  Abstract -.->|"拡張"| SMS

上図は、抽象クラスを基点とした拡張可能な設計を示しています。

依存性逆転原則(DIP)とインターフェース

高水準モジュールが低水準モジュールの具体的な実装に依存しないよう、インターフェースを介して設計します。

typescript// 抽象(インターフェース)
interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

interface NotificationService {
  notify(userId: string, message: string): Promise<void>;
}

// 高水準モジュール(ビジネスロジック)
class UserRegistrationService {
  constructor(
    private userRepository: UserRepository,
    private notificationService: NotificationService,
  ) {}

  async register(userData: CreateUserDto): Promise<User> {
    const user = this.createUser(userData);
    await this.userRepository.save(user);
    await this.notificationService.notify(
      user.id,
      "ユーザー登録が完了しました",
    );
    return user;
  }

  private createUser(data: CreateUserDto): User {
    return {
      id: crypto.randomUUID(),
      name: data.name,
      email: data.email,
    };
  }
}

interface CreateUserDto {
  name: string;
  email: string;
}

設計パターン選択の判断フローチャート

この章では、実務で使える判断基準を図解します。

mermaidflowchart TD
  Start["クラス設計の開始"]
  Q1{"IS-A 関係が<br/>明確か?"}
  Q2{"共通の実装を<br/>提供したいか?"}
  Q3{"複数の機能を<br/>組み合わせたいか?"}
  Q4{"実装の詳細を<br/>隠蔽したいか?"}

  Inheritance["継承を使用"]
  Abstract["抽象クラスを使用"]
  Mixin["ミックスインを使用"]
  Interface["インターフェースを使用"]
  Composition["Composition を使用"]

  Start --> Q1
  Q1 -->|"はい"| Q2
  Q1 -->|"いいえ"| Q3
  Q2 -->|"はい"| Abstract
  Q2 -->|"いいえ"| Inheritance
  Q3 -->|"はい"| Mixin
  Q3 -->|"いいえ"| Q4
  Q4 -->|"はい"| Interface
  Q4 -->|"いいえ"| Composition

上図は、設計パターンを選択する際の判断フローを示しています。

具体的な判断基準まとめ

状況推奨パターン理由
明確な親子関係がある継承IS-A 関係を型で表現できる
処理フローは同じで一部だけ異なる抽象クラステンプレートメソッドが有効
独立した機能を複数のクラスに付与ミックスイン横断的関心事の分離
実装の差し替えを可能にしたいインターフェース + DIテスタビリティの向上
外部ライブラリを隠蔽したいComposition依存の局所化

実装時に避けるべきアンチパターン

この章では、よくある設計ミスとその回避法を示します。

アンチパターン 1:深すぎる継承階層

typescript// 避けるべき:深すぎる継承
class Entity {}
class AuditableEntity extends Entity {}
class SoftDeletable extends AuditableEntity {}
class Versioned extends SoftDeletable {}
class User extends Versioned {} // 5階層

// 推奨:ミックスインによる機能合成
const User = WithVersioning(WithSoftDelete(WithAudit(BaseEntity)));

アンチパターン 2:God Object(神クラス)

typescript// 避けるべき:何でもできるクラス
class ApplicationManager {
  handleUser() {
    /* ... */
  }
  processPayment() {
    /* ... */
  }
  sendNotification() {
    /* ... */
  }
  generateReport() {
    /* ... */
  }
  // 責任が多すぎる
}

// 推奨:責任ごとにクラスを分離
class UserService {
  /* ... */
}
class PaymentService {
  /* ... */
}
class NotificationService {
  /* ... */
}
class ReportService {
  /* ... */
}

アンチパターン 3:不適切な抽象化

typescript// 避けるべき:過度な抽象化
abstract class AbstractFactoryProducerManager {
  abstract createAbstractFactory(): AbstractFactory;
}

abstract class AbstractFactory {
  abstract createAbstractProduct(): AbstractProduct;
}

// 推奨:必要最小限の抽象化
interface PaymentMethod {
  process(amount: number): Promise<boolean>;
}

class CreditCard implements PaymentMethod {
  async process(amount: number): Promise<boolean> {
    // 実装
    return true;
  }
}

継承・抽象クラス・ミックスインの詳細比較

この章では、各パターンの特性を詳細に比較します。

観点継承抽象クラスミックスインインターフェース
コードの再利用高い高い高い不可
型安全性完全完全完全(静的)完全
結合度強い中程度弱い最も弱い
柔軟性低い中程度高い最も高い
学習コスト低い中程度高い低い
テスタビリティ低い中程度高い最も高い
使用推奨度限定的条件付き推奨推奨強く推奨

使い分けの具体例

継承が適切なケース

typescript// DOM 要素のカスタマイズなど、明確な IS-A 関係
class CustomButton extends HTMLButtonElement {
  connectedCallback() {
    this.addEventListener("click", this.handleClick);
  }
  private handleClick = () => {
    /* ... */
  };
}

抽象クラスが適切なケース

typescript// データベース接続など、共通処理があるテンプレート
abstract class Repository<T> {
  protected abstract tableName: string;

  async findById(id: string): Promise<T | null> {
    return this.query(`SELECT * FROM ${this.tableName} WHERE id = ?`, [id]);
  }

  protected abstract query(sql: string, params: unknown[]): Promise<T | null>;
}

ミックスインが適切なケース

typescript// 横断的関心事(ロギング、キャッシュ、バリデーション)
class ProductService extends WithValidation(
  WithCaching(WithLogging(BaseService)),
) {
  // ビジネスロジックに集中できる
}

まとめ

TypeScript のクラス設計において、継承・抽象クラス・ミックスインの選択は、コードの拡張性と保守性を大きく左右します。

本記事で解説した判断基準を整理すると、以下のようになります。

継承を選ぶ条件

  • 明確な IS-A 関係が存在する
  • 基底クラスが安定しており、変更頻度が低い
  • 継承階層が3階層以内に収まる

抽象クラスを選ぶ条件

  • 共通の処理フローがあり、一部を派生クラスで実装させたい
  • protected メンバーやコンストラクタの共有が必要
  • テンプレートメソッドパターンが有効

ミックスインを選ぶ条件

  • 独立した機能を複数のクラスに横断的に付与したい
  • 単一継承の制約を超えた機能合成が必要
  • 機能の組み合わせを柔軟に変更したい

インターフェースを選ぶ条件

  • 純粋な契約定義のみが必要
  • 依存性の注入を行いたい
  • 実装の詳細を隠蔽したい

設計に迷った場合は、まずインターフェースを検討し、必要に応じてミックスインや抽象クラスを導入する段階的なアプローチを推奨します。継承は最後の選択肢として、明確な IS-A 関係がある場合に限定してください。

静的型付けの恩恵を最大限に活かすためには、型システムと設計パターンの両方を理解し、プロジェクトの要件に応じた適切な選択を行うことが重要です。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;