abstract class(抽象クラス)とは、そのままでは new できず、継承して使うことを前提にしたクラスです。共通の処理は親に書き、子クラスごとに違う部分だけを「抽象メソッド」として本体なしで宣言します。 interface も「このメソッドを持つこと」を決められますが、こちらは型だけの存在で、実装やフィールドの初期値は持てません。

この記事では、abstract class と interface を7つの項目で比べた表を最初に置き、同じ題材を両方で書いたコードと、tsc が出力した JavaScript を並べます。new したとき・抽象メソッドを書き忘れたとき・抽象メソッドに本体を書いたときなどのエラー全文、抽象プロパティ、abstract new () => T という型の使い方、共通処理を親に置く雛形(テンプレートメソッド)の実行結果まで扱います。

確認環境:Ubuntu 24.04、TypeScript 7.0.2(npx tsc --version の表示は Version 7.0.2)、Node.js 22.22.2。エラー文は npx tsc -p . の出力をそのまま貼っています。

abstract class と interface の早見表

比べる点 abstract class interface
実装(本体のあるメソッド)を持てるか 持てる 持てない
フィールドの初期値 書ける 書けない(TS1246)
コンストラクタ 書ける 書けない
アクセス修飾子(protected・private) 使える(抽象メンバーに private は不可) 使えない(TS1070)
1つのクラスに複数指定 できない。extends は1つ(TS1174) できる。implements はいくつでも
instanceof で判定 できる できない(TS2693)
コンパイル後の JavaScript class として残る(抽象メンバーの宣言は消える) 何も残らない
使うときのキーワード extends implements
向いている場面 共通処理やフィールドを子クラスで共有したい 形だけを決めたい、複数の役割を組み合わせたい

「共通の実装を配りたいなら abstract class、形の約束だけなら interface」が基本の選び方です。 両方を組み合わせることもできます(後半の「extends は1つ、implements は複数」)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript abstract classとinterfaceの違いと例」

同じ題材を abstract class と interface で書く

表の行データを文字列に書き出す Exporter を、両方の書き方で作ります。以下のコードは、次の tsconfig.json を置いたフォルダで型チェックしています。実行は node ファイル名.ts です(Node.js 22.22.2 は型注釈を取り除いて .ts をそのまま実行できます)。

{
  "compilerOptions": {
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "moduleDetection": "force",
    "noEmit": true
  }
}

abstract class で書く

// with-abstract.ts
abstract class Exporter {
  protected lineCount = 0;
  readonly title: string;

  constructor(title: string) {
    this.title = title;
  }

  abstract readonly extension: string;
  abstract formatRow(row: string[]): string;

  export(rows: string[][]): string {
    const lines = rows.map((row) => this.formatRow(row));
    this.lineCount = lines.length;
    return `# ${this.title}.${this.extension} (${this.lineCount}行)\n${lines.join("\n")}`;
  }
}

class CsvExporter extends Exporter {
  readonly extension = "csv";
  formatRow(row: string[]): string {
    return row.join(",");
  }
}

const exporter: Exporter = new CsvExporter("stock");
console.log(exporter.export([["pen", "12"], ["note", "3"]]));
console.log(exporter instanceof Exporter);
$ node with-abstract.ts
# stock.csv (2行)
pen,12
note,3
true

export の流れ(各行を整形して、行数を数えて、見出しを付ける)は親の Exporter に1回だけ書いてあります。子の CsvExporter が書くのは、拡張子と1行の整形だけです。形式を増やすときも、同じ2つを書いた子クラスを足すだけで済みます。

interface で書く

// with-interface.ts
interface Exporter {
  readonly title: string;
  readonly extension: string;
  formatRow(row: string[]): string;
  export(rows: string[][]): string;
}

class CsvExporter implements Exporter {
  readonly extension = "csv";
  private lineCount = 0;
  readonly title: string;

  constructor(title: string) {
    this.title = title;
  }

  formatRow(row: string[]): string {
    return row.join(",");
  }

  export(rows: string[][]): string {
    const lines = rows.map((row) => this.formatRow(row));
    this.lineCount = lines.length;
    return `# ${this.title}.${this.extension} (${this.lineCount}行)\n${lines.join("\n")}`;
  }
}

const exporter: Exporter = new CsvExporter("stock");
console.log(exporter.export([["pen", "12"], ["note", "3"]]));
$ node with-interface.ts
# stock.csv (2行)
pen,12
note,3

interface は「何を持つか」を決めるだけなので、export の中身もフィールドもコンストラクタも、実装するクラスがすべて書きます。形式を増やすと export の本体も形式の数だけ書くことになります。そのかわり、CsvExporter は親クラスを持たないので、別のクラスを継承する余地が残ります。

tsc が出力した JavaScript を比べる

npx tsc with-abstract.ts --ignoreConfig --strict --target es2022 --outDir js で出力した JavaScript です(interface 版も同じオプション)。

// js/with-abstract.js
"use strict";
class Exporter {
    lineCount = 0;
    title;
    constructor(title) {
        this.title = title;
    }
    export(rows) {
        const lines = rows.map((row) => this.formatRow(row));
        this.lineCount = lines.length;
        return `# ${this.title}.${this.extension} (${this.lineCount}行)\n${lines.join("\n")}`;
    }
}
class CsvExporter extends Exporter {
    extension = "csv";
    formatRow(row) {
        return row.join(",");
    }
}
const exporter = new CsvExporter("stock");
console.log(exporter.export([["pen", "12"], ["note", "3"]]));
console.log(exporter instanceof Exporter);
// js/with-interface.js
"use strict";
class CsvExporter {
    extension = "csv";
    lineCount = 0;
    title;
    constructor(title) {
        this.title = title;
    }
    formatRow(row) {
        return row.join(",");
    }
    export(rows) {
        const lines = rows.map((row) => this.formatRow(row));
        this.lineCount = lines.length;
        return `# ${this.title}.${this.extension} (${this.lineCount}行)\n${lines.join("\n")}`;
    }
}
const exporter = new CsvExporter("stock");
console.log(exporter.export([["pen", "12"], ["note", "3"]]));
元のコード 出力された JavaScript
abstract class Exporter class Exporter として残る(abstract の語は消える)
abstract readonly extension: string; 消える
abstract formatRow(row: string[]): string; 消える
本体のあるメソッド export そのまま残る
interface Exporter { ... } まるごと消える

abstract class は実行時にも class として存在するので、instanceof で判定でき、共通メソッドも継承されます。 interface は型チェックが終わると跡形もなくなります。なお、「new できない」という制限は型チェックだけのもので、出力された JavaScript の class Exporter には new を止める仕組みは入っていません。

abstract class で出るエラー全文

new すると TS2511

// err-new.ts
abstract class Shape {
  abstract area(): number;
}
const shape = new Shape();
err-new.ts(4,15): error TS2511: Cannot create an instance of an abstract class.

日本語(npx tsc -p . --locale ja)では「抽象クラスのインスタンスは作成できません。」と表示されます。抽象メソッドの本体がないまま呼ばれることを防ぐためのエラーなので、new するのは抽象メンバーをすべて実装した子クラスにします。

抽象メンバーを書き忘れると TS2515・TS2654

// err-impl.ts
abstract class Shape {
  abstract readonly name: string;
  abstract area(): number;
  abstract perimeter(): number;
}

// 1つだけ足りない
class Square extends Shape {
  readonly name = "square";
  private size = 2;
  area(): number {
    return this.size ** 2;
  }
}

// 複数足りない
class Circle extends Shape {}

// abstract のまま引き継ぐならエラーにならない
abstract class Polygon extends Shape {
  readonly name = "polygon";
}
err-impl.ts(8,7): error TS2515: Non-abstract class 'Square' does not implement inherited abstract member perimeter from class 'Shape'.
err-impl.ts(17,7): error TS2654: Non-abstract class 'Circle' is missing implementations for the following members of 'Shape': 'name', 'area', 'perimeter'.

足りないメンバーが1つなら TS2515、2つ以上なら TS2654 になり、どちらも足りない名前がエラー文に出ます。 日本語では次の文です。

err-impl.ts(8,7): error TS2515: 非抽象クラス 'Square' は、クラス 'Shape' からの継承抽象メンバー 'perimeter' を実装しません。
err-impl.ts(17,7): error TS2654: 非抽象クラス 'Circle' には、'Shape' の次のメンバーの実装がありません: 'name', 'area', 'perimeter'。

3つめの Polygon のように、子クラス自身も abstract にすると、残りの抽象メンバーをさらに下の子クラスへ引き継げます。

抽象メンバーの書き方で出るエラー(TS1245 ほか)

// err-body.ts
abstract class Shape {
  abstract area(): number {
    return 0;
  }
}

class Plain {
  abstract area(): number;
}

abstract class Base {
  abstract name: string = "base";
  private abstract secret(): void;
  abstract static create(): Base;
}
err-body.ts(2,12): error TS1245: Method 'area' cannot have an implementation because it is marked abstract.
err-body.ts(8,3): error TS1244: Abstract methods can only appear within an abstract class.
err-body.ts(12,12): error TS1267: Property 'name' cannot have an initializer because it is marked abstract.
err-body.ts(13,11): error TS1243: 'private' modifier cannot be used with 'abstract' modifier.
err-body.ts(14,12): error TS1243: 'static' modifier cannot be used with 'abstract' modifier.
エラー 原因 直し方
TS2511 抽象クラスを new した 子クラスを new する
TS2515 抽象メンバーが1つ未実装 表示された名前のメンバーを子クラスに書く
TS2654 抽象メンバーが複数未実装 表示された名前をすべて書く。または子クラスも abstract にする
TS1245 抽象メソッドに本体を書いた 既定の動きを持たせたいなら abstract を外して通常のメソッドにする
TS1244 abstract でないクラスに抽象メソッドを書いた クラスに abstract を付ける
TS1267 抽象プロパティに初期値を書いた 初期値は子クラスで書く。親で決めたいなら abstract を外す
TS1243 abstract と private、または static を一緒に書いた 子クラスから見えるよう protected か public にする
TS2715 親のコンストラクタで抽象プロパティを読んだ getter やメソッドの中で読む

interface で出るエラー

早見表の interface 側の「書けない」「できない」も、同じように確かめました。

// err-iface.ts
interface Exporter {
  extension: string = "csv";
  private secret: string;
}
err-iface.ts(2,23): error TS1246: An interface property cannot have an initializer.
err-iface.ts(3,3): error TS1070: 'private' modifier cannot appear on a type member.
// err-iface2.ts
interface Named {
  name: string;
}
declare const value: unknown;
if (value instanceof Named) {
}

abstract class A {
  abstract a(): void;
}
abstract class B {
  abstract b(): void;
}
class C extends A, B {
  a() {}
  b() {}
}
err-iface2.ts(5,22): error TS2693: 'Named' only refers to a type, but is being used as a value here.
err-iface2.ts(14,20): error TS1174: Classes can only extend a single class.

TS2693 は「Named は型としてだけ存在し、値としては使えない」という意味です。interface は JavaScript に残らないため、instanceof の右側には書けません。

抽象プロパティ:値も子クラスに決めさせる

abstract はメソッドだけでなくプロパティにも付けられます。最初の例の abstract readonly extension: string; がそれで、「子クラスは必ずこの値を用意する」という約束を、親の共通処理から安心して読める形で表せます。

1つだけ気をつけたいのは、親のコンストラクタの中では子クラスのプロパティがまだ入っていないことです。

// err-prop.ts
abstract class Report {
  abstract readonly title: string;
  readonly header: string;

  constructor() {
    this.header = `== ${this.title} ==`;
  }
}

class MonthlyReport extends Report {
  readonly title = "月次レポート";
}

console.log(new MonthlyReport().header);
err-prop.ts(6,30): error TS2715: Abstract property 'title' in class 'Report' cannot be accessed in the constructor.

型チェックを通さずに node err-prop.ts で動かすと、出力は == undefined == でした。親のコンストラクタが動く時点では、子クラスのフィールドの代入がまだ行われていないためです。getter にして「使うときに読む」形にすると、エラーも消え、値も入ります。

// err-prop-fix.ts
abstract class Report {
  abstract readonly title: string;

  // コンストラクタではなく、使うときに読む
  get header(): string {
    return `== ${this.title} ==`;
  }
}

class MonthlyReport extends Report {
  readonly title = "月次レポート";
}

console.log(new MonthlyReport().header);
$ node err-prop-fix.ts
== 月次レポート ==

共通処理を親に置く雛形(テンプレートメソッド)

abstract class がいちばん力を発揮するのは、「処理の流れは同じで、途中の一部だけが違う」ときです。流れを親のメソッドに書き、違う部分だけを抽象メソッドにして子クラスに書かせる形を、テンプレートメソッドと呼びます。

// template-method.ts
type Row = Record<string, string | number>;

abstract class Importer {
  // 共通の流れ(子クラスでは書き換えない)
  run(text: string): Row[] {
    console.log(`[${this.name}] 開始`);
    const rows = this.parse(text);
    const valid = rows.filter((row) => this.validate(row));
    console.log(`[${this.name}] ${rows.length}件中 ${valid.length}件を取り込み`);
    this.afterImport(valid);
    return valid;
  }

  // 差分:子クラスが必ず書く
  protected abstract readonly name: string;
  protected abstract parse(text: string): Row[];

  // 差分:必要なときだけ上書きする(既定の動きあり)
  protected validate(row: Row): boolean {
    return Object.values(row).every((v) => v !== "");
  }
  protected afterImport(rows: Row[]): void {}
}

class CsvImporter extends Importer {
  protected readonly name = "CSV";
  protected parse(text: string): Row[] {
    const [head = "", ...lines] = text.trim().split("\n");
    const keys = head.split(",");
    return lines.map((line) => {
      const cells = line.split(",");
      return Object.fromEntries(keys.map((key, i) => [key, cells[i] ?? ""]));
    });
  }
}

class JsonImporter extends Importer {
  protected readonly name = "JSON";
  protected parse(text: string): Row[] {
    return JSON.parse(text);
  }
  protected override validate(row: Row): boolean {
    return super.validate(row) && Number(row.stock) >= 0;
  }
  protected override afterImport(rows: Row[]): void {
    console.log(`[JSON] 合計在庫: ${rows.reduce((sum, row) => sum + Number(row.stock), 0)}`);
  }
}

console.log(new CsvImporter().run("name,stock\npen,12\nnote,\nink,5"));
console.log(new JsonImporter().run('[{"name":"pen","stock":12},{"name":"note","stock":-1}]'));
$ node template-method.ts
[CSV] 開始
[CSV] 3件中 2件を取り込み
[ { name: 'pen', stock: '12' }, { name: 'ink', stock: '5' } ]
[JSON] 開始
[JSON] 2件中 1件を取り込み
[JSON] 合計在庫: 12
[ { name: 'pen', stock: 12 } ]

この雛形のメンバーは3種類に分かれます。

種類 例 書き方 子クラスの役割
共通の流れ run public の通常メソッド そのまま使う
必ず書く差分 name、parse protected abstract 書かないと TS2515・TS2654 になる
必要なら変える差分 validate、afterImport protected の通常メソッド(既定の動きあり) 変えたいときだけ override で上書きする

抽象メンバーを protected にしておくと、外からは run だけが見え、parse を直接呼ばれることがなくなります。CsvImporter は name と parse の2つだけ、JsonImporter は加えて検証と後処理を上書きしています。

extends は1つ、implements は複数

クラスが継承できる親は1つですが、interface はいくつでも implements できます。abstract class と interface を組み合わせると、共通の実装は親から受け取り、役割は interface で足す形になります。

// multi.ts
interface Printable {
  print(): string;
}
interface Comparable<T> {
  compareTo(other: T): number;
}

abstract class Entity {
  readonly id: number;
  constructor(id: number) {
    this.id = id;
  }
  abstract label(): string;
}

class Member extends Entity implements Printable, Comparable<Member> {
  readonly name: string;
  constructor(id: number, name: string) {
    super(id);
    this.name = name;
  }
  label(): string {
    return `会員#${this.id}`;
  }
  print(): string {
    return `${this.label()} ${this.name}`;
  }
  compareTo(other: Member): number {
    return this.id - other.id;
  }
}

const members = [new Member(2, "suzuki"), new Member(1, "sato")];
members.sort((a, b) => a.compareTo(b));
console.log(members.map((m) => m.print()));
console.log(members[0] instanceof Entity, members[0] instanceof Member);

function isPrintable(value: unknown): value is Printable {
  return typeof value === "object" && value !== null && "print" in value;
}
console.log(isPrintable(members[0]), isPrintable({ id: 1 }));
$ node multi.ts
[ '会員#1 sato', '会員#2 suzuki' ]
true true
true false

2行目は instanceof Entity と instanceof Member の結果で、抽象クラスでも instanceof で判定できることがわかります。Printable は interface なので instanceof が使えず、3行目のように「print というプロパティを持つか」を調べる関数を自分で書いて判定しています。

abstract new () => T:クラスそのものを引数で受け取る

クラスを引数として受け取る関数では、型を new () => T と書きます。この型は「new できるクラス」を表すので、抽象クラスを渡すと TS2345 になります。

// ctor-err.ts
abstract class Shape {
  abstract area(): number;
}
class Square extends Shape {
  area() { return 4; }
}

function create<T extends Shape>(ctor: new () => T): T {
  return new ctor();
}
create(Shape);

function make<T extends Shape>(ctor: abstract new () => T): T {
  return new ctor();
}
ctor-err.ts(11,8): error TS2345: Argument of type 'typeof Shape' is not assignable to parameter of type 'new () => Shape'.
  Cannot assign an abstract constructor type to a non-abstract constructor type.
ctor-err.ts(14,10): error TS2511: Cannot create an instance of an abstract class.

抽象クラスも受け取りたいときは、型の先頭に abstract を付けて abstract new () => T と書きます。こちらは抽象クラスも通常のクラスも受け取れますが、2つめのエラーのとおり、関数の中で new はできません。

// ctor-type.ts
abstract class Shape {
  abstract area(): number;
  describe(): string {
    return `${this.constructor.name}: ${this.area().toFixed(2)}`;
  }
}
class Square extends Shape {
  area() { return 4; }
}
class Circle extends Shape {
  area() { return Math.PI; }
}

// new できるクラスだけを受け取る
function create<T extends Shape>(ctor: new () => T): T {
  return new ctor();
}

// 抽象クラスも受け取れる(中で new はしない)
function nameOf<T extends Shape>(ctor: abstract new () => T): string {
  return ctor.name;
}

// 抽象クラスを受け取って、機能を足したクラスを返す
function withLabel<T extends abstract new (...args: any[]) => Shape>(Base: T) {
  abstract class Labeled extends Base {
    label(): string {
      return `[${this.describe()}]`;
    }
  }
  return Labeled;
}

console.log(create(Square).describe());
console.log(nameOf(Shape), nameOf(Circle));

class LabeledCircle extends withLabel(Shape) {
  area() { return Math.PI; }
}
console.log(new LabeledCircle().label());
$ node ctor-type.ts
Square: 4.00
Shape Circle
[LabeledCircle: 3.14]
型 抽象クラスを渡す 通常のクラスを渡す 関数の中で new
new () => T TS2345 通る できる
abstract new () => T 通る 通る TS2511

abstract new が役立つのは、3つめの withLabel のように「受け取ったクラスを継承して、機能を足したクラスを返す」関数です。抽象クラスを土台にしても使えるようになります。

protected なコンストラクタとの違い

「外から new させない」方法には、コンストラクタを protected にする形もあります。

// protected-ctor.ts
class Connection {
  readonly url: string;
  protected constructor(url: string) {
    this.url = url;
  }

  static open(url: string): Connection {
    return new Connection(url);
  }
}

class PooledConnection extends Connection {
  readonly poolSize: number;
  constructor(url: string, poolSize: number) {
    super(url);
    this.poolSize = poolSize;
  }
}

const a = Connection.open("db://main");
const b = new PooledConnection("db://main", 5);
const c = new Connection("db://main");
protected-ctor.ts(22,11): error TS2674: Constructor of class 'Connection' is protected and only accessible within the class declaration.
比べる点 abstract class protected なコンストラクタ
外から new TS2511 TS2674
クラス自身の static メソッドから new できない できる
子クラスに実装を求める 抽象メンバーでできる できない
向いている場面 子クラスが差分を書く前提の土台 生成の入口を static メソッドに限りたい

Connection は抽象メンバーを持たない完成したクラスなので、Connection.open の中では new できます。「未完成だから new できない」のが abstract class、「入口を絞りたいから new を隠す」のが protected なコンストラクタ、と覚えると使い分けやすくなります。

どちらを選ぶかの判断表

状況 選ぶもの
複数のクラスで同じ処理やフィールドを共有したい abstract class
処理の流れを固定し、一部だけ子クラスに書かせたい abstract class(テンプレートメソッド)
実行時に instanceof で種類を見分けたい abstract class
メソッドの形だけを約束したい interface
1つのクラスに複数の役割を持たせたい interface を複数 implements
クラスを使わないオブジェクトにも同じ形を求めたい interface
共通実装も、複数の役割もほしい abstract class を extends し、interface を implements

よくある質問

Q. 抽象メソッドが1つもない abstract class は書けますか? A. 書けます。「このクラスは直接 new せず、必ず継承して使う」という意図だけを示したいときに使えます。withLabel の中の Labeled がその例で、自分では抽象メソッドを宣言していません。

Q. abstract class を implements することはできますか? A. クラスも型として扱えるため、implements の右側に書くことはできます。ただしその場合は形の約束だけが使われ、親の実装は引き継がれません。本体のあるメソッドも自分で書かないと TS2720(Did you mean to extend ...? と extends を案内するエラー)になりました。共通処理を受け取りたいときは extends を使います。

Q. 抽象メソッドを実装するとき override は必要ですか? A. 必須ではありません。この記事の CsvExporter や CsvImporter は override なしで型チェックを通っています。既定の動きがあるメソッドを上書きするときは、JsonImporter のように override を書いておくと、上書きであることが読み手に伝わります。

Q. interface から abstract class に書き換える目安はありますか? A. interface を実装するクラスが増え、同じメソッド本体を何度も書いていると気づいたときが目安です。共通部分を abstract class に移し、クラスごとに違う部分だけを抽象メンバーとして残すと、同じコードを1か所にまとめられます。

まとめ

  • abstract class は new できない継承用のクラスで、共通の実装・フィールド・コンストラクタを持てる。interface は形の約束だけを持つ
  • コンパイル後、abstract class は class として残り、interface は消える。そのため instanceof で判定できるのは abstract class だけ
  • new すると TS2511、抽象メンバーが1つ未実装なら TS2515、複数なら TS2654、抽象メソッドに本体を書くと TS1245 になる
  • 抽象プロパティは親のコンストラクタでは読めない(TS2715)。getter やメソッドの中で読む
  • 処理の流れを親に、差分を protected abstract にして子に書かせると、テンプレートメソッドの形になる
  • extends は1つ、implements は複数。共通実装は abstract class、役割の追加は interface で組み合わせる
  • クラスを引数で受け取る型は、抽象クラスも通すなら abstract new () => T と書く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript abstract classとinterfaceの違いと例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?