TypeScript | 関数・クラス・ジェネリクス:クラス設計 – 抽象クラスの設計

TypeScript TypeScript
スポンサーリンク

ゴール:「抽象クラスは“共通の型と骨組みだけを持つ設計図”」だと理解する

抽象クラスは一言でいうと、

「new できないけれど、“こういうクラスであってほしい”という共通ルールを決めるクラス」

です。

  • 共通のプロパティ・メソッドは持つ
  • でも「ここは必ず子クラスが実装してね」という“穴”も持つ
  • その結果、「バラバラな子クラスたちを、同じように扱える」

という状態を作るための仕組みです。

ここを腹で理解できると、
「継承を使う意味」が一段クリアになります。


抽象クラスとは何か:new できない“半分だけ完成したクラス”

abstract class の基本構文

まずは形から。

abstract class Shape {
  // 共通のプロパティ
  constructor(public color: string) {}

  // 共通の具体メソッド
  describe(): void {
    console.log(`色: ${this.color}`);
  }

  // 子クラスが必ず実装しなければならない“抽象メソッド”
  abstract area(): number;
}
TypeScript

ここでのポイントは二つです。

一つ目:abstract class と書かれたクラスは new できません

// const s = new Shape("red"); // エラー:抽象クラスはインスタンス化できない
TypeScript

二つ目:abstract area(): number; のような「中身のないメソッド宣言」を持てること。
これは「このクラスを継承するなら、必ず area() を実装しなさい」という“約束”です。

つまり抽象クラスは、

  • 具体的な処理を持つ部分(共通ロジック)
  • 子クラスに実装を強制する部分(抽象メソッド)

を両方持つ、「半分だけ完成したクラス」です。

子クラスで“穴”を埋める

抽象クラスを継承する側は、抽象メソッドを必ず実装しなければいけません。

class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);
  }

  area(): number {
    return this.radius * this.radius * Math.PI;
  }
}

class Rectangle extends Shape {
  constructor(color: string, public width: number, public height: number) {
    super(color);
  }

  area(): number {
    return this.width * this.height;
  }
}

const shapes: Shape[] = [
  new Circle("red", 10),
  new Rectangle("blue", 5, 8),
];

for (const s of shapes) {
  s.describe();          // 抽象クラス側の共通メソッド
  console.log(s.area()); // 子クラスごとの実装
}
TypeScript

ここでの構造はこうです。

  • Shape:色を持つこと・area() を持つことを“型として”保証する
  • Circle / Rectangle:それぞれの形に応じた area() の中身を実装する
  • 呼び出し側:Shape 型として扱うだけで、具体的な形ごとの面積計算を意識しなくてよい

「共通のインターフェース+一部だけ子クラスに任せる」
これが抽象クラスの本質です。


抽象クラスとインターフェースの違い

ざっくり言うと「中身を持てるかどうか」

インターフェースとの違いがよく聞かれるポイントなので、
初心者向けに一番大事なところだけ押さえます。

インターフェース:

interface Notifier {
  send(message: string): void;
}
TypeScript
  • 中身(実装)は持てない
  • 「こういうメソッドを持っていてね」という“形”だけを決める

抽象クラス:

abstract class Notifier {
  abstract send(message: string): void;

  log(message: string): void {
    console.log("送信ログ:", message);
  }
}
TypeScript
  • 抽象メソッド(中身なし)も持てる
  • 具体メソッド(中身あり)も持てる
  • 共通ロジックを“ここに書いて共有”できる

つまり、

「共通の処理もまとめたい」なら抽象クラス
「形だけ決めたい」ならインターフェース

という使い分けが基本になります。


抽象クラス設計のコア:何を“共通”にして、何を“抽象”にするか

共通ロジックは抽象クラスに閉じ込める

例えば、「通知を送る」仕組みを考えます。

abstract class Notifier {
  constructor(public sender: string) {}

  // 共通の前処理
  protected format(message: string): string {
    return `[from: ${this.sender}] ${message}`;
  }

  // 子クラスに実装を強制する“抽象メソッド”
  abstract send(message: string): void;
}

class EmailNotifier extends Notifier {
  send(message: string): void {
    const formatted = this.format(message); // 共通ロジックを再利用
    console.log("メール送信:", formatted);
  }
}

class LineNotifier extends Notifier {
  send(message: string): void {
    const formatted = this.format(message);
    console.log("LINE送信:", formatted);
  }
}
TypeScript

ここでの設計のポイントは、

  • 「送信前にメッセージを整形する」という共通ロジックは抽象クラスに書く
  • 「どう送るか(メールか LINE か)」という具体ロジックは子クラスに任せる
  • 抽象クラスは send の“存在”を強制しつつ、format という“便利機能”も提供している

ということです。

「全員が必ず持つべきメソッド」と
「全員が共通で使える便利メソッド」

この2つをセットで提供できるのが、抽象クラスの強みです。

抽象メソッドは「ここは必ず子クラスが決めて」と宣言する場所

抽象メソッドを書くときは、
「ここはこのクラスでは決めきらない。
でも、継承するなら絶対に実装してほしい」という場所です。

abstract class Job {
  constructor(public name: string) {}

  abstract run(): void; // ここは各ジョブごとに違う

  execute(): void {
    console.log(`ジョブ開始: ${this.name}`);
    this.run(); // 子クラスの実装が呼ばれる
    console.log(`ジョブ終了: ${this.name}`);
  }
}

class BackupJob extends Job {
  run(): void {
    console.log("バックアップを実行");
  }
}

class CleanupJob extends Job {
  run(): void {
    console.log("不要ファイルを削除");
  }
}

const jobs: Job[] = [
  new BackupJob("バックアップ"),
  new CleanupJob("クリーンアップ"),
];

for (const job of jobs) {
  job.execute();
}
TypeScript

ここでは、

  • execute() の前後処理(ログ出力)は抽象クラスが一元管理
  • 真ん中の「何をするか」は run() 抽象メソッドとして子クラスに任せる
  • 呼び出し側は job.execute() だけ呼べばよい

という構造になっています。

「流れ(テンプレート)は親が決める。
中身の一部だけ子が埋める」

このパターンは実務でかなりよく出てきます。


抽象クラスを使うか迷ったときの判断軸

「new されるべき“完成品”か?それとも“土台”か?」

クラスを書いていて、
「これは直接 new されるべきか?」と一瞬考えてみてください。

もし、

  • これ単体では意味が弱い
  • 必ず何かの“具体クラス”に継承される前提
  • 共通ロジックと共通インターフェースをまとめたい

という感覚があるなら、それは抽象クラス候補です。

逆に、

  • そのクラス自体を new して普通に使う
  • 継承されるかどうかはおまけ程度

なら、普通のクラスで十分です。

「インターフェース+ユーティリティ関数」で済むなら、そっちも検討する

抽象クラスは強力ですが、
「継承」という仕組みを持ち込むぶん、依存関係が強くなります。

もし、

  • 共通ロジックは「ただの関数」として外に出せる
  • クラス同士を強く結びつけたくない

という状況なら、

interface Shape {
  area(): number;
}

function describeShape(shape: Shape): void {
  console.log("面積:", shape.area());
}
TypeScript

のように、
インターフェース+関数で表現するほうがシンプルなことも多いです。

「共通ロジックを“継承”で共有したいのか?
それとも“関数”として共有したいのか?」

ここを意識して選べるようになると、
抽象クラスの出番が“本当に必要なところ”に絞られていきます。


まとめ:抽象クラスの設計を自分の言葉で整理すると

最後に、あなた自身の言葉でこう整理してみてください。

抽象クラスは、

  • new できない「半分だけ完成したクラス」
  • 共通のプロパティ・具体メソッドを持てる
  • 抽象メソッドで「子クラスが必ず実装すべき穴」を宣言できる
  • 「共通の流れは親が決めて、一部だけ子に任せる」設計に向いている

そして設計のポイントは、

  • 「ここは全サブクラスで共通にしたい」ロジックを抽象クラスに集約する
  • 「ここは各サブクラスごとに違うべき」部分を抽象メソッドにする
  • その結果、「親型(抽象クラス)としてまとめて扱える」ようにする

今のあなたのコードの中で、
「似たようなクラスがいくつもあって、
同じような前処理・後処理を書き散らしている場所」があれば、
そこを抽象クラス+抽象メソッドに整理できないか考えてみてください。

一度そこがハマると、
“コピペだらけのクラス群”が、“共通ルールを持ったきれいな階層”に変わっていきます。

タイトルとURLをコピーしました