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 {}
この設計では、以下の問題が発生しました。
- 基底クラスの変更が全派生クラスに波及:
AuditableEntityのログ形式を変更したところ、意図しない箇所でテストが失敗 - 機能の選択的利用が不可能:バージョン管理が不要なエンティティでも
VersionedEntityの機能を継承してしまう - テストの困難さ:各階層のモック作成が複雑化
検証の結果、このような継承階層は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;
}
動作確認済みのコードです。UserService は log、getLogs、getCached、setCached の全メソッドを利用できます。
静的ミックスインと動的ミックスインの違い
| 観点 | 静的ミックスイン | 動的ミックスイン |
|---|---|---|
| 決定タイミング | コンパイル時 | 実行時 |
| 型安全性 | 完全 | 部分的(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 関係がある場合に限定してください。
静的型付けの恩恵を最大限に活かすためには、型システムと設計パターンの両方を理解し、プロジェクトの要件に応じた適切な選択を行うことが重要です。
関連リンク
著書
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript 5.8の型推論を比較・検証する 強化点と落とし穴の回避策
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
