1. ホーム
  2. TypeScript

【TypeScript】抽象クラス(abstract)の使い方|継承を前提とした共通処理の設計を解説

Share

クラスを設計していると、「このクラスは単体で使わせたくない。必ず継承して、共通の土台として使ってほしい」と思う場面があります。TypeScript の abstract class(抽象クラス)は、まさにそのためのしくみです。直接 new でインスタンス化できない「継承前提のクラス」を作り、共通処理は親にまとめつつ、個別処理はサブクラスに実装を強制できます。この記事では、abstract クラス・abstract メソッドの書き方、実装済みメソッドとの組み合わせ(テンプレートメソッド的な設計)、interface との違いを、図形クラスの実践例を交えて初心者〜中級者向けに解説します。

継承を前提とした「土台」クラスを作りたいとき

たとえば「図形」という概念を考えてみます。図形には「面積を求める」という共通の役割がありますが、面積の計算方法は円と長方形でまったく違います。つまり「図形」そのものは実体を持たず、円や長方形といった具体的な図形になって初めて意味を持ちます。

こうした「それ自体は実体化させたくないが、共通の枠組みは決めておきたい」クラスを表現するのが抽象クラスです。抽象クラスには次の2つの特徴があります。1つは、単体ではインスタンス化できないこと。もう1つは、中身を書かない「抽象メソッド」を宣言でき、それを継承したサブクラスに実装を強制できることです。共通処理は親クラスに実装として書き、図形ごとに異なる部分だけをサブクラスに任せる、という役割分担ができます。

abstract class は new でインスタンス化できない

クラス宣言の前に abstract を付けると、そのクラスは抽象クラスになります。抽象クラスは new で直接インスタンスを作れません。試みるとコンパイルエラーになります。

shape-base.ts
abstract class Shape {
  constructor(public name: string) {}
}

// 抽象クラスは直接インスタンス化できない
// Cannot create an instance of an abstract class.
const shape = new Shape("図形");

new Shape(...) の行で Cannot create an instance of an abstract class. というエラーが出ます。抽象クラスはあくまで「継承されて使われること」を前提にしているため、それ自身をインスタンス化する行為が禁止されているのです。実際に使うときは、この Shapeextends したサブクラス(後述の Circle など)を new します。

abstract メソッドはサブクラスに実装を強制する

抽象クラスの中では、メソッドの前に abstract を付けて「抽象メソッド」を宣言できます。抽象メソッドは本体({ ... } の中身)を書かず、名前・引数・戻り値の型といったシグネチャだけを書きます。これは「このメソッドは必ず用意してほしいが、中身はサブクラスごとに決めてね」という約束を表します。

shape-abstract-method.ts
abstract class Shape {
  constructor(public name: string) {}

  // 本体を書かず、シグネチャだけを宣言する抽象メソッド
  abstract area(): number;
}

class Circle extends Shape {
  constructor(public radius: number) {
    super("円");
  }

  // 抽象メソッドを実装しないとコンパイルエラーになる
  area(): number {
    return this.radius * this.radius * Math.PI;
  }
}

const circle = new Circle(10);
console.log(circle.area()); // 314.159...

CircleShape を継承しているので、抽象メソッド area() を必ず実装しなければなりません。もし Circle の中で area() を実装し忘れると、Non-abstract class 'Circle' does not implement inherited abstract member 'area' from class 'Shape'. というコンパイルエラーになります。このように、抽象メソッドは「実装のし忘れ」をコンパイル時に検出してくれる安全装置として働きます。

共通処理は親に実装する(テンプレートメソッド的な設計)

抽象クラスは、抽象メソッドだけでなく、中身のある通常のメソッドやプロパティも持てます。ここが interface との大きな違いです。「サブクラスごとに異なる部分」だけを abstract にし、「どのサブクラスでも共通の処理」は親クラスに実装として書けます。共通処理の中から抽象メソッドを呼び出す形にすると、処理の流れは親で固定しつつ、可変部分だけをサブクラスに差し込む設計ができます。これはテンプレートメソッドと呼ばれる考え方です。

shape.ts
abstract class Shape {
  constructor(public name: string) {}

  // サブクラスごとに異なる部分は抽象メソッドにする
  abstract area(): number;

  // どの図形でも共通の処理は、実装として親に書く
  describe(): string {
    // 共通処理の中から、可変部分の area() を呼び出す
    return `${this.name}の面積は ${this.area().toFixed(2)} です`;
  }
}

class Circle extends Shape {
  constructor(public radius: number) {
    super("円");
  }

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

class Rectangle extends Shape {
  constructor(public width: number, public height: number) {
    super("長方形");
  }

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

const shapes: Shape[] = [new Circle(10), new Rectangle(4, 5)];
for (const shape of shapes) {
  // describe() は共通、area() は各図形の実装が使われる
  console.log(shape.describe());
}
// 円の面積は 314.16 です
// 長方形の面積は 20.00 です

describe() は「名前と面積を文章にする」という共通処理なので、親クラス Shape に実装として一度だけ書いています。その中で呼んでいる area() は抽象メソッドなので、実際に動くのは CircleRectangle がそれぞれ実装した area() です。共通部分を一箇所にまとめられるので重複が減り、可変部分の実装漏れは abstract がコンパイル時に防いでくれます。shapes のように Shape[] 型でまとめて扱えるのも、共通の親クラスを持つ利点です。

抽象プロパティと protected abstract

abstract はメソッドだけでなくプロパティにも付けられます。抽象プロパティは「サブクラスが必ず用意する値」を型として約束させるものです。また、protected と組み合わせて protected abstract と書くと、「サブクラスには実装を強制するが、外部からは触らせない」内部向けの抽象メンバーを作れます。

abstract-property.ts
abstract class Notifier {
  // サブクラスが必ず用意するプロパティ
  abstract readonly channel: string;

  // 外からは呼べないが、サブクラスには実装を強制する内部メソッド
  protected abstract format(message: string): string;

  // 共通処理:整形はサブクラスに任せ、送信の流れは親で固定する
  send(message: string): void {
    const text = this.format(message);
    console.log(`[${this.channel}] ${text}`);
  }
}

class SlackNotifier extends Notifier {
  readonly channel = "slack";

  // protected abstract なので実装は必須。外からは呼べない
  protected format(message: string): string {
    return `:bell: ${message}`;
  }
}

const notifier = new SlackNotifier();
notifier.send("デプロイが完了しました");
// [slack] :bell: デプロイが完了しました

channel は抽象プロパティなので、SlackNotifier は必ず値を用意しなければなりません。format()protected abstract なので、サブクラスでの実装は必須である一方、notifier.format(...) のようにクラスの外から呼ぼうとするとエラーになります。整形のしかた(可変部分)はサブクラスに任せつつ、送信の流れ(共通部分)は親の send() に固定する、という役割分担がきれいに表現できます。

interface との違いを整理する

「実装を強制する」という点では interface も似ています。両者は目的が重なる場面もありますが、性質はかなり異なります。ざっくり言うと、interface は「型としての契約(形だけ)」、抽象クラスは「実装も持てる継承元のクラス」です。主な違いを表にまとめます。

観点interfaceabstract class
実装(中身のあるメソッド)持てない(形の定義だけ)持てる(共通処理を書ける)
継承・実装の数複数まとめて実装できる(多重実装)継承できるのは1つだけ(単一継承)
コンパイル後の存在消える(型情報だけで実行時に残らない)クラスとして残る(実行時に存在する)
プロパティの初期値持てない持てる
主な用途型の契約を定義する共通の実装を持つ土台を作る

使い分けの目安はシンプルです。「守ってほしい型の形(メソッドの一覧)を定義したいだけ」なら interface を使います。一方、「共通の処理を親に持たせて、その一部だけをサブクラスに実装させたい」なら抽象クラスを使います。interfaceimplements で複数まとめて適用できる柔軟さがある反面、実装を書けません。抽象クラスは実装を共有できる反面、継承できる親は1つだけです。共通コードを再利用したいかどうかが、選択の分かれ目になります。

抽象メソッドでエラーが出るときに確認すること

抽象クラスを使い始めたとき、意図しないコンパイルエラーに戸惑うことがあります。よく出る2つのケースを見ておきましょう。

サブクラスで実装したのにエラーが消えない

does not implement inherited abstract member というエラーが実装後も残る場合、メソッド名や引数・戻り値の型が抽象メソッドの宣言と一致していない可能性があります。抽象メソッド側が area(): number なのにサブクラス側が area(): string だったり、引数の数が違ったりすると、TypeScript は「別のメソッド」と見なし、抽象メソッドは未実装のままだと判断します。宣言と実装のシグネチャが完全に一致しているかを確認してください。

抽象メソッドに本体を書いてしまっている

抽象メソッドは本体を持てません。abstract area(): number { return 0; } のように { ... } を書くと、Method 'area' cannot have an implementation because it is marked abstract. というエラーになります。共通の実装を親に持たせたいなら、そのメソッドは abstract を外して普通のメソッドとして書きます。サブクラスに実装を強制したいなら本体を書かず、シグネチャだけにします。「実装を持たせたい」のか「強制したい」のかで、abstract を付けるかどうかを決めましょう。

まとめ

抽象クラス(abstract class)は、継承して使うことを前提とした「土台」となるクラスを作るためのしくみです。抽象クラスは new で直接インスタンス化できず、abstract メソッドは本体を書かずシグネチャだけを宣言して、サブクラスに実装を強制します。一方で、中身のある通常のメソッドやプロパティも持てるため、共通処理は親にまとめ、可変部分だけを abstract にする「テンプレートメソッド」的な設計ができます。abstract プロパティや protected abstract を使えば、必須の値や外部非公開の内部処理も表現できます。型の形だけを定義したいなら interface、共通の実装を共有しつつ一部を強制したいなら抽象クラス、と目的で使い分けるのがポイントです。

参考ページ