MENU

JavaScriptの派生クラス徹底解説:なぜこう書くのかまで理解できる継承設計ガイド

目次

派生クラスとは?


JavaScriptにおける派生クラス(サブクラス)とは、既存のクラスを継承して新しいクラスを定義する仕組みのことです。extendsキーワードを用いることで、親クラス(スーパークラス)のプロパティやメソッドを引き継ぎつつ、独自の機能を追加したり、既存の振る舞いを上書き(オーバーライド)したりできます。これにより、共通処理を再利用しながら、より具体的で専門的なクラスを構築できるのが特徴です。

イティセル/コード専門官

要するに、親クラスを土台にして子クラスを投入することで、ゲームでいう「体力増加」や「攻撃力アップ」のように、既存の能力に新しい力を上乗せするイメージです。

派生クラスの基礎と意図

JavaScriptのクラスは構文糖衣です。内部はプロトタイプ継承ですが、class/extends/superにより「意図の明示」と「安全な初期化順序」が保証されます。継承は抽象化・ドメイン表現・ビルトイン拡張に有効ですが、過剰な階層化は可読性と進化性を毀損します。継承は「is-a」が明確な場合に限定し、曖昧ならコンポジションを優先しましょう。

イティセル/コード専門官

つまり、古いクラスと新しいクラスを何度も融合しすぎると壊れます。継承階層が深くなるほど依存関係が絡み合い、ちょっとした変更が全体に波及する“脆い設計”になってしまうのです。

extendsとsuperの実装的ポイント

コンストラクタ初期化とメソッド委譲

•  必須: 派生クラスのconstructorでthisを触る前にsuper(…)を呼ぶ

•  目的: 親の初期化とthisバインドの確立

•  メソッド: super.method()は親の同名メソッドに委譲

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a sound.`;
  }
}

class Dog extends Animal {
  constructor(name, breed = 'Shiba') {
    super(name);
    this.breed = breed;
  }
  speak() {
    const base = super.speak();
    return `${base} Specifically, ${this.breed} barks.`;
  }
}

const shiba = new Dog('Inu', 'Shiba');
console.log(shiba.speak());
リクナ / JavaScript統括官

Inu makes a sound. Specifically, Shiba barks.

実行結果
Inu makes a sound. Specifically, Shiba barks.

1. 親クラスの定義

イティセル/コード専門官

まずは基底クラス Animal を定義します。  
コンストラクタで name を受け取り、speak メソッドで基本的なメッセージを返します。

要するに、Animal は親クラスとして「名前を持つ存在に対して、標準的なメッセージを返す」という基本機能を提供しているのです。
つまり、Animal はメソッドを呼び出すと、自分が持っているメッセージを返す仕組みになっています。

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a sound.`;
  }
}

2. 派生クラスの定義

イティセル/コード専門官

次に Dog クラスを Animal から継承します。

•  constructor 内では、必ず最初に super(name) を呼び出すことで、親クラスの初期化と this の有効化を行います。
•  speak メソッドでは super.speak() を呼んで親の処理を再利用し、その上に犬種の情報を追加しています。

要するに、Dog は子クラスであり、親クラスの機能を引き継ぎながら、自分専用の処理を加えているのです。  
つまり、Dog は「親の仕組みを活かしつつ、自分らしい振る舞いを追加している」というイメージです。

class Dog extends Animal {
  constructor(name, breed = 'Shiba') {
    super(name);
    this.breed = breed;
  }
  speak() {
    const base = super.speak();
    return `${base} Specifically, ${this.breed} barks.`;
  }
}
イティセル/コード専門官

最後に Dog クラスをインスタンス化して、speak を呼び出します。  
要するに、子クラスに親クラスの機能を引き継ぎつつ、新しい情報(ここでは ‘Inu’ と ‘Shiba’)を渡して実行してみると…ぽちっ

const shiba = new Dog('Inu', 'Shiba');
console.log(shiba.speak());
リクナ / JavaScript統括官

Inu makes a sound. Specifically, Shiba barks.

実行結果
Inu makes a sound. Specifically, Shiba barks.
イティセル/コード専門官

この流れは、親クラスの力を借りて、子クラスで工夫しながら追加の情報を入れて完成させる、というイメージです。

thisの確立とnew.target

new.targetで「どのクラスでnewされたか」を判定可能(擬似抽象クラスに使える)

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error('Shape is abstract; instantiate a subclass.');
    }
  }
  area() { throw new Error('Implement area().'); }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }
  area() { return Math.PI * this.radius ** 2; }
}

const c = new Circle(10);
console.log(c.area());
リクナ / JavaScript統括官

314.1592653589793

実行結果
314.1592653589793
イティセル/コード専門官

Shape は「図形の設計図」としての役割を持ち、直接 new されるとエラーを投げる仕組みになっています。
area メソッドも「子クラスで必ず実装してください」というルールを示すだけで、自分自身では処理を持ちません。つまり Shape は枠組みを定めるだけの存在であり、子クラスがなければ実際には動かない、いわば「設計図だけのクラス」なのです。

class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error('Shape is abstract; instantiate a subclass.');
    }
  }
  area() { throw new Error('Implement area().'); }
}
イティセル/コード専門官

Circle は Shape を継承した具体的なクラスで、円という図形を表す。
コンストラクタの最初で super() を呼び出すことで親クラスの初期化を済ませ、その後に半径を受け取って this.radius に保存する。
そして area() をオーバーライドし、円の面積を計算する処理を実装している。
つまり「親が決めたルールを守りつつ、自分専用の振る舞いを追加する存在」だ。
要するに、親クラスは「面積を計算しなければならない」というルールを持ち、子クラスはそのルールに従って実際の計算処理を実装する、という設定になっている。

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }
  area() { return Math.PI * this.radius ** 2; }
}
イティセル/コード専門官

ここで new Circle(10) を実行すると、new.target は Circle になるためエラーは発生せず、半径 10 の円オブジェクトが生成される。そして c.area() を呼び出すと Math.PI * 10 ** 2 が計算され、結果として 314.1592653589793 が出力される。
ここで Math.PI * 10 ** 2 という式は、円の面積の公式  に半径 10 を代入したものであり、
つまり π×100=314.1592653589793…を表している。

const c = new Circle(10);
console.log(c.area());
リクナ / JavaScript統括官

314.1592653589793

実行結果
314.1592653589793

staticメソッド・プロパティの継承

•  staticはクラス自身にぶら下がる

•  子は親のstaticを継承し、superで呼び出し可能

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class Logger {
  static prefix = '[LOG]';
  static log(msg) { console.log(`${this.prefix} ${msg}`); }
}

class FileLogger extends Logger {
  static prefix = '[FILE]';
  static log(msg) {
    super.log(msg);
  }
}

FileLogger.log('hello');
リクナ / JavaScript統括官

[FILE] hello

実行結果
[FILE] hello
イティセル/コード専門官

このコードでは、まず Logger というクラスが定義されている。Logger には prefix という静的プロパティがあり、その値は ‘[LOG]’ に設定されている。
また、log という静的メソッドも定義されていて、呼び出されると this.prefix を参照しながらメッセージを出力する仕組みになっている。ここで重要なのは、static が付いているため、これらはインスタンスではなくクラスそのものに属しているという点だ。
したがって new Logger() からは呼べず、Logger.log(“…”) のようにクラス名を通して呼び出すことになる。

要するに、親クラスである Logger は、インスタンスを作らなくてもクラスそのものが窓口になって処理を受け付ける、いわば「受付係」のような役割を果たしているのです。

class Logger {
  static prefix = '[LOG]';
  static log(msg) { console.log(`${this.prefix} ${msg}`); }
}
イティセル/コード専門官

次に FileLogger クラスは Logger を継承して定義されている。ここでは prefix を ‘[FILE]’ に上書きし、さらに log メソッドを再定義している。ただし FileLogger.log の中身は super.log(msg) だけであり、これは「親クラスの log メソッドを呼び出す」という意味になる。

要するに、親の [LOG] という看板を子が [FILE] に掛け替えた状態で、以降はその新しい看板を使って受付をしている、というイメージです。

class FileLogger extends Logger {
  static prefix = '[FILE]';
  static log(msg) {
    super.log(msg);
  }
}
イティセル/コード専門官

最後に FileLogger.log(‘hello’) を実行すると、まず子クラスの log が呼ばれ、その中で super.log(msg) が実行される。すると親クラス Logger の log が動くのだが、その内部で使われている this は呼び出し元である FileLogger を指す。そのため this.prefix は Logger.prefix ではなく FileLogger.prefix を参照し、結果として [FILE] hello が出力される。

要するに、親のメソッドを利用しながらも、実際に使われる設定は子クラスのものに切り替わる仕組みになっている。

FileLogger.log('hello');
リクナ / JavaScript統括官

[FILE] hello

実行結果
[FILE] hello
イティセル/コード専門官

つまり、親クラスのやり方を学んで取り入れつつ、子クラスは自分らしい設定に変えて実行するって感じです。

ビルトイン拡張と深い落とし穴

Arrayの派生とSymbol.species

Arrayのメソッド(map/filterなど)は既定で「同じコンストラクタ」を返します。返却型を標準Arrayに固定したければSymbol.speciesを上書きします。

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class MyArray extends Array {
  first() { return this[0]; }
  static get [Symbol.species]() { return Array; }
}

const xs = new MyArray(1, 2, 3);
const ys = xs.map(x => x * 2);
console.log(ys instanceof MyArray);
console.log(ys instanceof Array);
console.log(xs.first());
リクナ / JavaScript統括官

false(Array)
true
1

実行結果
falseArray
true
1
イティセル/コード専門官

MyArray クラスは Array を継承しているため、map や filter といった配列メソッドをそのまま利用することができます。さらに first() というインスタンスメソッドを定義しており、これは配列の先頭要素を返す役割を持っています。
また、static get [Symbol.species]() を定義することで、このクラスから派生したメソッド(たとえば map や filter)が返すコンストラクタを指定できます。この例では Array を返すようにしているため、map などを呼び出した際には結果が常に標準の Array 型として返されるように強制されます。

要するに、Symbol.species を実装した時点で「MyArray のドアは閉じて、代わりに Array のドアを開ける」ようなイメージです。つまり、map や filter の結果は必ず標準の Array から出てくるようになるわけです。

class MyArray extends Array {
  first() { return this[0]; }
  static get [Symbol.species]() { return Array; }
}
イティセル/コード専門官

const xs = new MyArray(1, 2, 3); とすると、xs は MyArray クラスから生成されたインスタンスになります。その中身は [1, 2, 3] という配列で、つまり「MyArray が [1, 2, 3] を保持している状態」と言えます。

const xs = new MyArray(1, 2, 3);
イティセル/コード専門官

通常であれば、MyArray は Array を継承しているため、map の結果も同じく MyArray のインスタンスとして返されます。ところが、このクラスでは Symbol.species を Array に上書きしているため、返却されるのは常に標準の Array になります。その結果、ys の中身は [2, 4, 6] ですが、その型は Array となります。

要するに、MyArray クラスから呼び出した map は本来なら MyArray を返そうとしますが、Symbol.species のルールによって「返却型は必ず Array」と決められているため、結果は Array になります。
イメージとしては、Symbol.species がリーダーとして方針を決め、map はその指示に従う部下のような関係です。

const ys = xs.map(x => x * 2);
イティセル/コード専門官

ys は標準の Array であり MyArray ではないため、ys instanceof MyArray の判定結果は false になります。
一方で、ys は確かに Array であるため、ys instanceof Array は true となります。
また、xs は MyArray のインスタンスなので独自に定義した first() メソッドを利用でき、その結果として配列の先頭要素である 1 が返されます。

console.log(ys instanceof MyArray);
console.log(ys instanceof Array);
console.log(xs.first());
リクナ / JavaScript統括官

false(Array)
true
1

false
true
1
イティセル/コード専門官

なぜ 1 が返るのかを見てみましょう。  
下のコードにあるように、
first() { return this[0]; }
と書かれています。これは「配列の 0 番目の要素を返す」という処理です。  
今回の new MyArray(1, 2, 3) では、配列の 0 番目の要素は 1 なので、xs.first() の実行結果は 1 になります。

class MyArray extends Array {
  first() { return this[0]; }
  static get [Symbol.species]() { return Array; }
}
イティセル/コード専門官

返却型が派生クラスのままだと、他者コードとの互換性が下がる可能性があります。そのため、拡張 API は「入力時のみ」提供し、出力は標準に寄せる設計の方が安全です。  

要するに、返却型の幅を標準に狭めておくことで、より堅牢で安全な設計になります。

Errorの派生とスタック・name整備

•  nameとmessageを明示しておく(デバッグ・ロギングに必須)

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class AppError extends Error {
  constructor(message, code = 'APP_ERROR') {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
  }
}

try {
  throw new AppError('Config missing', 'CONFIG_MISSING');
} catch (e) {
  console.error(e.name, e.code, e.message);
}
リクナ / JavaScript統括官

AppError CONFIG_MISSING Config missing

実行結果
AppError CONFIG_MISSING Config missing
イティセル/コード専門官

AppError は組み込みの Error を継承して作られた独自のエラークラスです。コンストラクタ内で super(message) を呼び出すことで、親クラスである Error が持つ message プロパティに値を設定しています。さらに、this.name = this.constructor.name によって name プロパティにはクラス名である “AppError” が格納されます。そして、this.code = code により独自のエラーコードをプロパティとして保持できるようになっており、エラーの種類や原因をより明確に識別できる設計になっています。

要するに、this.code = code で「エラーを識別するためのコード」をインスタンスにセットしている、というイメージです。

class AppError extends Error {
  constructor(message, code = 'APP_ERROR') {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
  }
}
イティセル/コード専門官

new AppError(…) を実行すると、新しいインスタンスが生成されます。このとき、エラーメッセージには “Config missing” が設定され、エラーコードには “CONFIG_MISSING” が割り当てられます。さらに、name プロパティにはクラス名である “AppError” が格納されます。

要するに、”Config missing” と “CONFIG_MISSING” は AppError インスタンスの中にプロパティとして保持されている、というイメージです。

throw new AppError('Config missing', 'CONFIG_MISSING');
イティセル/コード専門官

catch 節で受け取ったエラーオブジェクト e を確認すると、e.name には “AppError” が格納され、e.code には “CONFIG_MISSING” が設定されています。さらに、e.message には “Config missing” というエラーメッセージが保持されています。
要するに、
•  e.name → “AppError”
•  e.code → “CONFIG_MISSING”
•  e.message → “Config missing”
という結果になります。

catch (e) {
  console.error(e.name, e.code, e.message);
}
リクナ / JavaScript統括官

AppError CONFIG_MISSING Config missing

実行結果
AppError CONFIG_MISSING Config missing
イティセル/コード専門官

え?
•  e.name → “AppError”
•  e.code → “CONFIG_MISSING”
•  e.message → “Config missing”
になったのかって?理由は簡単です。
message と code はコンストラクタに渡した引数の順番で決まり、AppError クラスなので name プロパティにはクラス名 “AppError” が設定されるのです。

Mixin(機能の合成)

単機能を小さく分け、必要な時に合成。状態とAPIの衝突に注意。

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

const Flyable = Base => class extends Base {
  fly() { return `${this.name} is flying`; }
};

const Swimable = Base => class extends Base {
  swim() { return `${this.name} is swimming`; }
};

class Animal { constructor(name) { this.name = name; } }
class Duck extends Swimable(Flyable(Animal)) {}

const d = new Duck('Kamo');
console.log(d.fly());
console.log(d.swim());
リクナ / JavaScript統括官

Kamo is flying
Kamo is swimming

実行結果
Kamo is flying
Kamo is swimming
イティセル/コード専門官

Flyable は「飛べる」機能を追加する Mixin、Swimable は「泳げる」機能を追加する Mixin です。  
どちらも「基底クラスを受け取り、それを拡張した新しいクラスを返す関数」として実装されています。

要するに、Flyable は「飛ぶ力」を、Swimable は「泳ぐ力」を持っていて、必要に応じてクラスに合成できます。
イメージとしては「機能のパーツが待機していて、必要なときに取り付け可能」という感じです。

const Flyable = Base => class extends Base {
  fly() { return `${this.name} is flying`; }
};

const Swimable = Base => class extends Base {
  swim() { return `${this.name} is swimming`; }
};
イティセル/コード専門官

次に、Animal クラスをベースとして Duck クラスを定義する際に、Flyable と Swimable を合成します。これにより、Duck は「動物であり、飛べて、泳げる」という性質を持つクラスになります。

要するに、Animal はただの土台で、Flyable と Swimable は「後から取り付け可能なパーツ」。Duck はその両方を装着した完成形、というイメージです。
わかりやすくいうと、Animal は仲介役みたいな立ち位置です。

class Animal { constructor(name) { this.name = name; } }
イティセル/コード専門官

Duck クラスは、まず Animal クラスをベースにしています。そこに Flyable Mixin を適用することで「飛ぶ機能」を持ったクラスが生成されます。
さらに、その結果に Swimable Mixin を適用することで「泳ぐ機能」も加わります。
要するに、Duck は Flyable と Swimable を合成した結果を持っているのです。

class Duck extends Swimable(Flyable(Animal)) {}
イティセル/コード専門官

Duck クラスは、Animal を土台にして「飛ぶ機能」と「泳ぐ機能」を合成したものです。だから new Duck(‘Kamo’) としてインスタンスを作ると、そのアヒルは名前を持ちながら、自然に「飛ぶ」ことも「泳ぐ」こともできるようになります。

要するに、Duck は生まれた瞬間から両方のスキルを身につけているキャラクター、というイメージです。

const d = new Duck('Kamo');
console.log(d.fly());
console.log(d.swim());
リクナ / JavaScript統括官

Kamo is flying
Kamo is swimming

実行結果
Kamo is flying
Kamo is swimming

コンポジション(委譲の明示)

実装詳細を部品に閉じ込め、明確な境界で委譲する。テスト容易・進化耐性が高い。

イティセル/コード専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class Engine {
  start() { return 'engine start'; }
}

class Car {
  constructor(engine = new Engine()) {
    this.engine = engine;
  }
  move() {
    const s = this.engine.start();
    return `${s}; car moves`;
  }
}

const car = new Car();
console.log(car.move());
リクナ / JavaScript統括官

engine start; car moves

実行結果
engine start; car moves
イティセル/コード専門官

Engine クラスは、車の中の「エンジン」という部品を表しています。その責任範囲はシンプルで、start() メソッドによって「エンジンを始動する」という処理だけを担当します。また、このクラスは Car に依存していないため、単体でテストしたり、別のエンジン実装に差し替えたりすることが容易です。


要するに、Engine クラスは“車の仕組みの中で独立したエンジン部品そのもの”を表しています。

class Engine {
  start() { return 'engine start'; }
}
イティセル/コード専門官

Car クラスは「車」を表すクラスです。その責任範囲は、自分の中に engine を保持することにあります。コンストラクタではデフォルトで new Engine() を持ちますが、必要に応じて外部から別のエンジンを渡すことも可能です。つまり、Car はエンジンという部品を内部に組み込むことで成り立っており、これが「コンポジション(部品として持つ)」の具体例となっています。

要するに、class Car が Engine を“受け取って持つ”感じです。

class Car {
  constructor(engine = new Engine()) {
    this.engine = engine;
  }
イティセル/コード専門官

Car クラスの move() メソッドは、車が「動く」という動作を表現しています。その際、Car 自身がエンジンの仕組みを直接書くのではなく、this.engine.start() を呼び出して処理を任せています。これが「委譲」と呼ばれる仕組みです。つまり、Car は「車が動く」という大きな責任を担いながら、細かい「エンジン始動」の責任は Engine に委ねているのです。


要するに、move() を呼ぶと車が動きますが、その裏側では Car が自分でエンジンを動かしているのではなく、this.engine.start() によってエンジンに始動をお願いしている、というイメージです。


わかりやすく言えば、move() はチーム全体をまとめるリーダーであり、this.engine.start() はエンジン担当のリーダーです。move() が動けば、エンジン担当リーダーも一緒に動いてくれる、そんな協力関係になっています。

move() {
    const s = this.engine.start();
    return `${s}; car moves`;
  }
}
リクナ / JavaScript統括官

engine start; car moves

実行結果
engine start; car moves
イティセル/コード専門官

s は変数で、this.engine.start() の戻り値である “engine start” が代入されています。  
そのため、テンプレートリテラル ${s}; car moves を実行すると、s の中身が展開されて “engine start; car moves” という文字列になります。

イティセル/コード専門官

おーい、ミカユナ、僕疲れたから君が解説してよ

ミカユナ/コード副専門官

え”え”え”え”

イティセル/コード専門官

じゃあ、次からよろしくね

ミカユナ/コード副専門官

む、無理ぃぃぃぃぃぃぃぃぃぃぃっ!!

リクナ / JavaScript統括官

もごもご…

現代JSの継承テクニック: プライベート、アクセサ、ファクトリ

フィールドの可視性と封装

#privateはサブクラスからも直接アクセス不可。アクセサで拡張面を用意。

ミカユナ/コード副専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class Counter {
  #value = 0;
  increment() { this.#value++; }
  get value() { return this.#value; }
}

class SafeCounter extends Counter {
  increment() {
    if (this.value >= 10) return;
    super.increment();
  }
}

const c = new SafeCounter();
for (let i = 0; i < 15; i++) c.increment();
console.log(c.value);
リクナ / JavaScript統括官

10

実行結果
10
ミカユナ/コード副専門官

Counter クラスでは、#value がプライベートフィールドとして定義されています。
この値は this.#value を通じてしかアクセスできず、外部やサブクラスから直接触れることはできません。increment() メソッドによってカウントを1つ増やし、get value() によって現在の値を読み取ることができます。つまり、外部からは counter.value を使って値を参照することはできますが、直接書き換えることはできない仕組みになっており、安全なカウンターとして機能します。

要するに、Counter クラスは独立していて、#value があるおかげで外部から直接触られることがなく、安全に値を管理できます。内部では範囲内で 1 つずつ値を増やして動作する仕組みになっているんです。

わかりやすくいうと、#value はクラスの外には出られないけれど、外部からは用意された窓口(getter やメソッド)を通じて関わることができる、そんなイメージです。

class Counter {
  #value = 0;
  increment() { this.#value++; }
  get value() { return this.#value; }
}
ミカユナ/コード副専門官

SafeCounter クラスは Counter を継承しています。その中で increment() メソッドをオーバーライドし、値が 10 以上になった場合はそれ以上カウントアップしないように制御しています。また、this.value は getter を経由して参照できるものの、プライベートフィールドである #value そのものには直接アクセスすることはできません。

要するに、SafeCounter クラスは Counter クラスを引き継いで、その上で「値が 10 に達したらそれ以上カウントアップしない」という制御を加えています。

class SafeCounter extends Counter {
  increment() {
    if (this.value >= 10) return;
    super.increment();
  }
}
ミカユナ/コード副専門官

SafeCounter クラスでは、increment() を何度呼んでも値は 10 で止まります。そこでサンプルコードでは、あえて 15 回ループさせて「10 を超えても増えない」という挙動を確認しています。

10回でも動作は確認できますが、余裕を持って 15 回まわすことで「上限で止まる」ことがよりはっきりわかるんです。

const c = new SafeCounter();
for (let i = 0; i < 15; i++) c.increment();
console.log(c.value);
リクナ / JavaScript統括官

10

実行結果
10

ファクトリ+擬似抽象

直接newさせず、安定した生成手順を提供。拡張点を限定する。

ミカユナ/コード副専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class BaseClient {
  constructor(token) {
    if (new.target === BaseClient) {
      throw new Error('Use create()');
    }
    this.token = token;
  }
  static create(token) { return new RestClient(token); }
}

class RestClient extends BaseClient {
  fetchJSON(path) { /* ... */ }
}

const c = BaseClient.create("abc");
console.log(c instanceof RestClient);
console.log(c instanceof BaseClient);
console.log(c.token);
リクナ / JavaScript統括官

true
true
abc

実行結果
true
true
abc
ミカユナ/コード副専門官

BaseClient は基底クラスとして設計されており、直接 new されることを禁止しています。
そのため、コンストラクタ内で new.target を確認し、もし BaseClient 自体をインスタンス化しようとした場合にはエラーを投げる仕組みになっています。これによって「擬似抽象クラス」としての役割を果たしているのです。
さらに、代わりの生成手段として static create(token) というファクトリメソッドが用意されており、このメソッドを通じて RestClient のインスタンスが生成されるようになっています。

要するに、BaseClient は親クラスで、直接触られるのを防ぐために new.target が見張ってくれているんです。
そして、正しい入り口である create(token) を通せば、安心して子クラスのインスタンスを作れるようになっている。つまり「勝手に new させないで、ちゃんと窓口を通してね」という仕組みなんですね。

class BaseClient {
  constructor(token) {
    if (new.target === BaseClient) {
      throw new Error('Use create()');
    }
    this.token = token;
  }
  static create(token) { return new RestClient(token); }
}
ミカユナ/コード副専門官

RestClient は BaseClient を継承した具体的な実装クラスです。このクラスは fetchJSON などの実際の処理を担っており、利用者が直接操作する対象になります。
また、BaseClient.create() メソッドを呼び出すと返されるのは、この RestClient のインスタンスです。

要するに、実際に動いて処理をしてくれるのは RestClient クラスなんです。

class RestClient extends BaseClient {
  fetchJSON(path) { /* ... */ }
}
ミカユナ/コード副専門官

BaseClient.create(“abc”) を呼び出すと、内部では new RestClient(“abc”) が実行されます。

その結果、変数 c には RestClient のインスタンスが代入されます。
さらに RestClient は BaseClient を継承しているため、c は RestClient と BaseClient の両方のインスタンスとして判定されます。
加えて、コンストラクタで渡した “abc” は c.token に格納されているので、そのまま出力されます。

const c = BaseClient.create("abc");
console.log(c instanceof RestClient);
console.log(c instanceof BaseClient);
console.log(c.token);
リクナ / JavaScript統括官

true
true
abc

実行結果
true
true
abc
ミカユナ/コード副専門官

わかりにくいかもしれませんか、落ち着いて考えてみましょう。

new.target は「設計図(BaseClient)を直接 new されないように守る見張り役」です。BaseClient.create(“abc”) を使うのは、設計図に侵入せず、正しい窓口から完成品(RestClient)を受け取るための流れです。
もし new BaseClient(“abc”) を許してしまうと、設計図そのものに「お前も abc になれ」と命じる状態になり、未完成なインスタンスができてしまいます。これを防ぐのが new.target です。

ミカユナ/コード副専門官

true で確認する理由は、
instanceof が true かどうかを確認するのは、「オブジェクトが設計通りに生成され、親子関係(BaseClient ⇄ RestClient)が正しく成り立っているか」を照合するためです。”abc” は単なるサンプル値で、気にしなくて構いません。

実戦のコード断片: まとめて“効く”例

1) 親ロジックの部分適用と契約強制

ミカユナ/コード副専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class Formatter {
  format(data) { throw new Error('Implement'); }
  print(data) { return `[PRINT] ${this.format(data)}`; }
}

class JsonFormatter extends Formatter {
  format(data) { return JSON.stringify(data); }
}

console.log(new JsonFormatter().print({ a: 1 }));
リクナ / JavaScript統括官

[PRINT] {“a”:1}

実行結果
[PRINT] {"a":1}
ミカユナ/コード副専門官

format(data) メソッドは、デフォルトでは throw new Error(‘Implement’) を記述しており、これは「必ず子クラスでオーバーライドしなければならない」という契約を強制する仕組みになっています。つまり、このメソッドをそのまま使おうとするとエラーが発生し、子クラス側で具体的な処理を実装しない限り利用できないようになっています。

一方で print(data) メソッドは、親クラスが提供する共通のロジックを担っています。内部で this.format(data) を呼び出し、その結果を [PRINT] … という形でラップして返す仕組みです。ここでは「出力の共通フォーマット」を親クラスが定義し、具体的なフォーマット処理は子クラスに委ねる構造になっています。

要するに、子クラスは「format を実装しないとエラーになる」という契約を結んでおり、親クラスは「print という共通の出力枠」を提供している状態です。
つまり、親が「受付窓口(print)」を用意し、子が「具体的な処理(format)」を必ず持ち込むことで、両者が役割分担しているわけです。

class Formatter {
  format(data) { throw new Error('Implement'); }
  print(data) { return `[PRINT] ${this.format(data)}`; }
}
ミカユナ/コード副専門官

JsonFormatter クラスは Formatter を継承しており、親クラスで契約として定義されていた format(data) メソッドをオーバーライドしています。その中で JSON.stringify(data) を返すように実装することで、親クラスが課していた「必ず子クラスで具体的な処理を定義しなければならない」という契約に応えた形になっています。

要するに、JsonFormatter は Formatter が定めたルールに従って動作している状態です。

class JsonFormatter extends Formatter {
  format(data) { return JSON.stringify(data); }
}
ミカユナ/コード副専門官

new JsonFormatter() でインスタンスを生成し、そのインスタンスに対して .print({ a: 1 }) を呼び出すと、まず親クラスで定義されている print メソッドが実行されます。
その内部では this.format({ a: 1 }) が呼ばれますが、this は JsonFormatter のインスタンスを指しているため、子クラスでオーバーライドされた format メソッドが実際に動作します。
この format は JSON.stringify({ a: 1 }) を返すように実装されているので、結果として {“a”:1} が返されます。
最終的に print メソッドはその値を [PRINT] … の形でラップし、[PRINT] {“a”:1} という文字列を返す仕組みになっています。

要するに、子クラスが処理を実装したことで、最終的な結果は [PRINT] {“a”:1} となり、親クラスが課していた契約が果たされたわけです。

console.log(new JsonFormatter().print({ a: 1 }));
リクナ / JavaScript統括官

[PRINT] {“a”:1}

実行結果
[PRINT] {"a":1}

2) ロギング基底+静的設定

ミカユナ/コード副専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

class BaseLogger {
  static level = 'info';
  static setLevel(lvl) { this.level = lvl; }
  log(msg) { console.log(`[${this.constructor.name}] ${msg}`); }
}

class DebugLogger extends BaseLogger {
  debug(msg) { if (this.constructor.level === 'debug') this.log(msg); }
}

DebugLogger.setLevel('debug');
new DebugLogger().debug('details');
リクナ / JavaScript統括官

[DebugLogger] details

実行結果
[DebugLogger] details
ミカユナ/コード副専門官

static level はクラス全体で共有されるログレベルを保持するためのプロパティで、初期値は ‘info’ に設定されています。static setLevel(lvl) はクラスに対して直接呼び出すことで、このログレベルを変更できる仕組みです。そして log(msg) メソッドは実際にメッセージを出力する役割を持ち、this.constructor.name を利用して「呼び出したクラス名」をラベルとして付与した形でログを表示します。

要するに、level は最初 ‘info’ に設定されていますが、必要に応じて自由に変更できます。たとえば setLevel(‘debug’) と呼べば、初期値の ‘info’ が ‘debug’ に切り替わり、その設定がクラス全体に反映されます。

class BaseLogger {
  static level = 'info';
  static setLevel(lvl) { this.level = lvl; }
  log(msg) { console.log(`[${this.constructor.name}] ${msg}`); }
}
ミカユナ/コード副専門官

DebugLogger クラスは BaseLogger を継承して定義されています。
その中の debug(msg) メソッドは、現在のクラスに設定されている静的プロパティ level が ‘debug’ の場合にのみログを出力するという条件付きの仕組みになっています。
また、このとき参照しているのはインスタンス固有の値ではなく、this.constructor.level を通じてクラスに設定された静的プロパティです。

要するに、このクラスは「ログレベルが ‘debug’ に設定されているときだけ debug() が動く」仕組みです。
だから setLevel(‘debug’) を呼んでおけば、debug(‘details’) と書いたときに [DebugLogger] details というログが出力されるわけです。

class DebugLogger extends BaseLogger {
  debug(msg) { if (this.constructor.level === 'debug') this.log(msg); }
}
ミカユナ/コード副専門官

DebugLogger.setLevel(‘debug’) を呼び出すことで、DebugLogger.level が ‘debug’ に設定されます。
その後、new DebugLogger().debug(‘details’) を実行すると、条件式 this.constructor.level === ‘debug’ が真となり、this.log(‘details’) が呼び出されます。結果として、[DebugLogger] details というログが出力されます。

要するに、このクラスは「ログレベルが ‘debug’ に設定されているときだけ debug() メソッドが動く」仕組みです。
だから setLevel(‘debug’) を呼んでおけば、debug(‘details’) を実行したときに [DebugLogger] details というログが出力されます。
逆にログレベルが ‘info’ のままだと、この debug() は何も出力しません。

DebugLogger.setLevel('debug');
new DebugLogger().debug('details');
リクナ / JavaScript統括官

[DebugLogger] details

実行結果
[DebugLogger] details

3) Mixin合成の衝突対策(名前空間化)

ミカユナ/コード副専門官

まずは派生クラスの仕組みを理解するために、サンプルコード全体を確認しましょう。その後、各部分を分解して説明します。

const Cacheable = Base => class extends Base {
  _cache = new Map();
  cache = {
    set: (k, v) => this._cache.set(k, v),
    get: k => this._cache.get(k)
  };
};

class User {}
class UserWithCache extends Cacheable(User) {}

const u = new UserWithCache();

u.cache.set('id', 123);
u.cache.set('name', 'Alice');

console.log(u.cache.get('id'));
console.log(u.cache.get('name'));
リクナ / JavaScript統括官

123
Alice

実行結果
123
Alice
ミカユナ/コード副専門官

Cacheable は関数で、任意のクラス Base を受け取り、それを継承した新しいクラスを返します。この新しいクラスには二つのプロパティが追加されます。
ひとつは _cache で、内部用の Map オブジェクトとしてインスタンスごとに独立したキャッシュ領域を保持します。
もうひとつは cache で、外部 API として提供されるオブジェクトです。ここには set と get がまとめられており、内部の _cache にアクセスするための窓口として機能します。

わかりやすくいうと、Cacheable は「元のクラスにキャッシュ機能という便利な引き出しを追加して、より使いやすいクラスに変えてくれる仕組み」です。

const Cacheable = Base => class extends Base {
  _cache = new Map();
  cache = {
    set: (k, v) => this._cache.set(k, v),
    get: k => this._cache.get(k)
  };
};
ミカユナ/コード副専門官

User は元のシンプルなクラスです。
これに対して Cacheable(User) を適用すると、「User を継承し、キャッシュ機能を持つ派生クラス」が生成されます。そしてそのクラスを UserWithCache として定義することで、User 本来の機能に加えてキャッシュ機能を備えたクラスが完成します。

これで User に「キャッシュ機能専用の窓口」として cache プロパティが追加され、外部から set や get を通じて安全に内部の _cache にアクセスできるようになりました。

class User {}
class UserWithCache extends Cacheable(User) {}
ミカユナ/コード副専門官

u は UserWithCache のインスタンスです。u.cache.set(‘id’, 123) を呼び出すと、内部の _cache に id → 123 が保存されます。

続いて u.cache.set(‘name’, ‘Alice’) を実行すると、同じく _cache に name → Alice が保存されます。その後、u.cache.get(‘id’) を呼び出すと 123 が返り、u.cache.get(‘name’) を呼び出すと Alice が返されます。

要するに、id: 123 と name: ‘Alice’ は一旦 _cache という引き出しに保存され、console.log で呼び出したときにその引き出しから取り出されて表示される、という仕組みです。

const u = new UserWithCache();

u.cache.set('id', 123);
u.cache.set('name', 'Alice');

console.log(u.cache.get('id'));
console.log(u.cache.get('name'));
リクナ / JavaScript統括官

123
Alice

実行結果
123
Alice

仕上げ: 使いどころの判断フレーム

明確な is-a 関係があるか  

→ 「A は B の一種である」と自然に言えるかどうか。  

例:Dog は Animal の一種 → OK。  

例:UserWithCache は User の一種 → OK。  

逆に「No」なら継承ではなく委譲や Mixin を検討すべき。

ミカユナ/コード副専門官

もしこの関係が成り立たない場合は、継承ではなく委譲や Mixin を検討すべきです。なぜなら、is-a 関係を無視して継承すると、コードを読む人にとって「なぜこのクラスがこの親を継承しているのか」が直感的に理解できず、設計が紛らわしくなるからです。

is-a を守ることは、わかりやすさと保守性を優先するための基本ルールです。他の人がコードを見たときにすぐに関係性を理解できるようにしておくことが、長期的な開発やチーム作業において大きな助けになります。

返却型・例外型は標準互換を保てるか  

→ サブクラスのメソッドが返すオブジェクトや投げるエラーが、親クラスと互換性を持つか。  

Symbol.species を使えば、継承したクラスのメソッドが返すインスタンス型を制御できる。  

また Error を継承する場合は name や stack を整えておくと標準的に扱える。

ミカユナ/コード副専門官

要するに、親クラスと子クラスの間で「契約の矛盾」を起こさないことが大切です。互換性を守ることで、親クラスとして子クラスを安全に扱えるようになり、設計全体の一貫性が崩れるのを防げます。逆にこれを怠ると、親クラスの利用者が予期せぬ挙動に遭遇し、設計図そのものが破綻してしまうのです。

拡張点を少数の抽象メソッドに集約できるか  

→ サブクラスがオーバーライドすべき箇所を最小限にまとめる。  

これにより部品化しやすく、テストも容易になる。  

(例:テンプレートメソッドパターン)

ミカユナ/コード副専門官

親クラスはあらかじめ「部品」を用意し、処理の流れをテンプレートとして定義します。子クラスはその部品を受け取り、自分なりに組み立てる担当です。こうすることで、親と子の役割分担が明確になり、コードは部品化されてテストもしやすくなります。

逆に、子クラスが大量の部品を自前で作り始めると、どこを修正すべきかが見えにくくなり、保守性が下がります。また、親クラスがすべての処理を抱え込むと、抽象化の意味がなくなり、コードは冗長で読みにくくなります。

要するに、親は部品を用意し、子は必要な部分だけを組み立てる。
この役割分担こそが、拡張性と可読性を両立させる鍵なのです。

階層の深さは 2〜3 に収められるか  

→ 継承ツリーが深くなると認知負荷が増し、変更に弱くなる。  

2〜3 階層程度に抑えるのが現実的。

ミカユナ/コード副専門官

子クラスはせいぜい 2 つ程度にとどめることで、親と子の担当分けが明確になります。逆に大量の子クラスを作ってしまうと、他の人がコードを読んだときに「どこで何をしているのか」がわからず、まるで迷子になったような状態に陥ってしまいます。

衝突回避の設計があるか  

→ Mixin を使う場合は名前空間化してプロパティ衝突を避ける。  

コンポジションを使う場合は「委譲の境界」を明確にして責務を分ける。

ミカユナ/コード副専門官

担当分けをはっきりさせ、誰が見ても理解できるシンプルな設計にすることが、衝突回避の基本です。これにより、機能同士の干渉を防ぎ、保守や修正も容易になります。大切なのは「複雑さで見せること」ではなく、シンプルで衝突のない構造を保つことです。

ミカユナ/コード副専門官

まとめると、継承を使うときは「is-a 関係」「互換性」「拡張点」「階層の深さ」「衝突回避」という5つの観点をチェックすることが大切です。
これらを意識することで、継承は単なる文法要素ではなく、再利用性と拡張性を両立させるための設計手法として活きてきます。強力だからこそ乱用せず、フレームを指針に“安心して使える継承”を実現しましょう。

まとめ

ここまで見てきたように、JavaScript における派生クラス(サブクラス)は、単なる「親クラスのコピー」ではなく、共通の仕組みを受け継ぎながら、自分だけの振る舞いを積み重ねていける柔軟な仕組みです。extends を使うことで、親クラスの資産をそのまま活かしつつ、必要な部分だけをオーバーライドしたり、新しいメソッドやプロパティを追加したりできます。

BaseLogger と DebugLogger の例では、親クラスのログ機能をそのまま利用しながら、子クラスで条件付きの debug() を実装することで、より具体的な用途に対応できることが分かりました。また、User に対して Cacheable(User) を適用し UserWithCache を定義した例では、シンプルなクラスにキャッシュ機能という「便利な引き出し」を後付けできることを体感しました。これらの例は、派生クラスが単なる継承の仕組みではなく、再利用性と拡張性を両立させる設計思想そのものであることを示しています。

振り返ってみると、派生クラスは「親の資産を受け継ぎながら、自分の色を加えていく」存在です。これはまるで、基礎をしっかり学んだ職人が、自分なりの工夫や技を重ねて独自の作品を生み出していく姿に似ています。コードの世界でも同じで、派生クラスを使うことで、シンプルな基盤から多彩なバリエーションを生み出し、より表現力豊かな設計を実現できるのです。

要するに、派生クラスは「再利用」と「拡張」の両輪を支える仕組みであり、オブジェクト指向の真髄を体現しています。今回の学びを通じて、ただの文法知識としてではなく、設計の道具として派生クラスをどう活かすかを意識できるようになれば、あなたのコードはより強く、しなやかに進化していくでしょう。

もしこの記事が役に立ったと思ったら、シェアやコメントで教えてください。  いただいた声を今後の改善に活かしていきます。  

最後まで読んでくださり、本当にありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITTIのアバター ITTI 運営長

私はフロントエンドエンジニアを目指す初心者で、ITパスポートを取得済みです。現在はCopilotを活用しながらAIや最新のIT技術を学び、日本の開発現場で求められるチーム開発やセキュリティの知識を吸収しています。学んだことはコードや仕組みを整理し、わかりやすく発信することで、同じ学びの途中にいる人たちの力になりたいと考えています。

コメント

コメントする

目次