JavaScriptのクラス実装でバグを激減させる!プロが実践する5つのベストプラクティス

JavaScriptのクラス実装でバグを激減させる5つのベストプラクティスを表現したアイキャッチイメージ。堅牢なオブジェクト指向設計を図解 プログラミング言語

JavaScriptのクラスは、ES2015での導入以降、オブジェクト指向設計の中核を担う機能として広く定着しました。
一方で、その柔軟さゆえに、ほんの少しの書き方の違いが予期せぬ挙動を生み、深刻なバグに発展するケースも少なくありません。

「メソッドをコールバックとして渡した途端、thisがundefinedになってクラッシュした」「privateのつもりで定義したプロパティが、外部から平気で書き換えられていた」——そんな経験はありませんか?クラスはアプリケーションの状態を保持する要です。
だからこそ、そこに潜むバグは影響範囲が広く、原因の特定にも時間を要しがちです。

本記事では、数多くのプロダクトの設計とコードレビューに携わる中で、実際に効果を実感してきた5つのベストプラクティスを紹介します。

  • カプセル化を徹底し、状態の変更経路を限定する
  • thisのバインド問題を構造的に回避する
  • コンストラクタで不正な状態を作らない
  • 継承よりも合成を優先する
  • 不変性を意識して、予測可能なクラスを設計する

いずれも難解な理論ではなく、今日から実装に取り入れられる具体的な手法ばかり。
「なんとなく動くコード」から一歩進んだ、堅牢で保守性の高い設計を目指したい方は、ぜひ最後までお付き合いください。

  1. なぜJavaScriptのクラス実装でバグが起きるのか?典型的な失敗パターンと原因
    1. this参照の罠:実行コンテキストで変わるメソッドの挙動
    2. カプセル化の欠如が引き起こす状態の不整合
    3. 継承の乱用が生む脆弱で変更しづらい設計
  2. ベストプラクティス1:カプセル化を徹底して状態の変更経路を限定する
    1. プライベートフィールド(#)でプロパティを外部から保護する
    2. ゲッターとセッターで意図した変更だけを許可する設計
  3. ベストプラクティス2:thisのバインド問題を構造的に回避する書き方
    1. アロー関数でthisを固定してイベントハンドラを安全にする
    2. bindメソッドとクラスフィールド構文の使い分け
  4. ベストプラクティス3:コンストラクタで不正な状態のインスタンスを作らない
    1. 初期化時のバリデーションで不正な値を検出する
    2. ファクトリメソッドで安全な生成フローを設計する
  5. ベストプラクティス4:継承よりも合成を優先する柔軟なクラス設計
    1. 継承の落とし穴:親クラスの変更が波及する密結合のリスク
    2. 合成と委譲で疎結合な実装を実現する方法
  6. ベストプラクティス5:不変性を意識した予測可能なクラスを設計する
    1. 状態を変更せず新しいインスタンスを返すメソッドの作り方
    2. Object.freezeとreadonlyで意図しない変更を防ぐ
  7. TypeScriptを組み合わせてクラスの型安全をさらに高める
    1. 型注釈と静的型付けでnull安全を確保する
    2. privateとreadonlyで可視性と不変性を明示する
  8. ユニットテストでクラスのバグを開発段階で検出する
    1. 状態遷移を検証するテストケースの設計
  9. まとめ:JavaScriptのクラス設計を見直してバグの少ないコードを書こう

なぜJavaScriptのクラス実装でバグが起きるのか?典型的な失敗パターンと原因

JavaScriptのクラスで起きやすいバグの原因を整理した図解。this参照の罠やカプセル化の欠如など典型的な失敗パターンを描いたイメージ

JavaScriptのクラスは見た目こそJavaやC++に似ていますが、その実態はプロトタイプベースの仕組みの上に構文糖衣として用意されたものです。
この言語的な出自が、従来のクラスベース言語の経験者ほど落とし穴にはまりやすい原因になっています。
具体的には、次の3つの特性がバグの温床になりがちです。

  • thisが呼び出し方によって動的に決まる
  • 言語仕様としてのアクセス制御が後付けであり、意識しないと状態が剥き出しになる
  • 継承の制約が緩く、設計上の破綻に気づきにくい

まずは、多くのバグレポートの根本原因となってきた典型的な失敗パターンを3つ、順に確認していきましょう。

this参照の罠:実行コンテキストで変わるメソッドの挙動

Javaなどの言語では、thisは常にそのメソッドを所有するインスタンスを指します。
ところがJavaScriptのthisは、関数の呼ばれ方によって値が変わる動的バインディングです。
この違いを知らないままメソッドを切り出すと、実行時に初めて問題が顕在化します。

class Counter {
  constructor() {
    this.count = 0;
  }
  increment() {
    this.count += 1;
  }
}

const counter = new Counter();

// メソッドだけを取り出してコールバックに渡す
const handler = counter.increment;
handler(); // TypeError: Cannot read properties of undefined

incrementを変数に代入した時点で、メソッドはインスタンスとの紐付けを失います。
結果としてthisはundefinedを参照し、行の最後で例外が発生します。
イベントリスナー登録や非同期処理でこの書き方をしてしまうコードは、実務でも頻繁に見かけます。

カプセル化の欠如が引き起こす状態の不整合

2つ目は、状態が外部から自由に書き換えられる問題です。
オブジェクト指向の文脈では、オブジェクトが取りうる状態の整合性はクラス自身が守るべきものですが、すべてのプロパティが公開されたクラスではその保証が成立しません。

class Cart {
  constructor() {
    this.items = [];
    this.total = 0;
  }
  addItem(price) {
    this.items.push(price);
    this.total += price;
  }
}

const cart = new Cart();
cart.addItem(1000);
cart.total = -9999; // 外部から直接書き換えられてしまう

この例ではitemsとtotalという2つのプロパティが「totalはitemsの合計である」という不変条件で結び付いています。
しかし書き込み経路が無制限のため、上のように合計だけが書き換えられ、不変条件はあっさり崩壊します。
状態が壊れた時点と原因の操作が離れるため、バグの再現と原因特定が非常に難しくなる点も厄介です。

継承の乱用が生む脆弱で変更しづらい設計

3つ目は、継承の濫用です。
継承は「子クラスは親クラスの振る舞いをすべて引き継ぐ」という強い結合を生みます。
親クラスの小さな変更が、すべての子孫クラスに波及するのです。

class Bird {
  fly() {
    console.log('飛んでいます');
  }
}

class Penguin extends Bird {}

const penguin = new Penguin();
penguin.fly(); // 飛べないはずのペンギンが飛んでいる

「ペンギンは鳥である」という生物学的な分類をそのままis-a関係に持ち込んだ結果、ペンギンが飛べてしまうという明らかな矛盾が生まれました。
これを子クラス側でメソッドを上書きして無理やり潰すと、今度は親クラスを参照するコードが子クラスでも正しく動くという前提(リスコフの置換原則)が崩れ、予測不能な挙動の温床になります。

これらの失敗パターンを整理すると、次の表のようになります。

失敗パターン 根本原因 代表的な症状
this参照の罠 動的なthisバインディング 実行時例外、undefined参照
カプセル化の欠如 書き込み経路の無制限な公開 状態の不整合、再現困難なバグ
継承の乱用 誤ったis-a関係による密結合 変更の波及、破壊的な上書き

いずれも言語仕様への理解不足と、設計上の規律の欠如が組み合わさって発生するものです。
つまり、原因が構造的にわかっている以上、対策も構造的に講じられるはず。
次章からは、その具体的な解決策を5つのベストプラクティスとして紹介していきます。

ベストプラクティス1:カプセル化を徹底して状態の変更経路を限定する

プライベートフィールドとゲッターでクラス内部の状態を保護する設計を表した図解イメージ

前章で示したCartクラスの破綻——合計金額だけが外部から書き換えられる問題——の根本原因は、状態の変更経路が無制限に公開されていたことにあります。
オブジェクト指向におけるカプセル化とは、単にデータを隠すことではありません。
不変条件を検証できる書き込み経路だけを残し、それ以外をすべて塞ぐための設計規律です。

なぜそこまで徹底する必要があるのでしょうか。
理由は単純で、書き込み経路が限定されれば、検証処理をその経路に集約できるからです。
結果として「このクラスの状態は常に整合している」という保証が、クラス内部のコードだけで完結して成立します。

幸い、現代のJavaScriptにはこの規律を実装する機能が標準で備わっています。
プライベートフィールドとアクセサ、この2つを順に見ていきましょう。

プライベートフィールド(#)でプロパティを外部から保護する

ES2022で正式仕様となったプライベートフィールドは、プロパティ名の先頭に#を付けることで、クラス外部からのアクセスを言語仕様レベルで禁止する機能です。
前章のBankAccount的な例で確認してみます。

class BankAccount {
  #balance = 0;

  deposit(amount) {
    if (!Number.isFinite(amount) || amount <= 0) {
      throw new Error('入金額には正の数を指定してください');
    }
    this.#balance += amount;
  }

  withdraw(amount) {
    if (amount > this.#balance) {
      throw new Error('残高が不足しています');
    }
    this.#balance -= amount;
  }

  get balance() {
    return this.#balance;
  }
}

const account = new BankAccount();
account.deposit(5000);
console.log(account.balance); // 5000

account.#balance = 0; // SyntaxError

残高を変更できる手段はdepositとwithdrawの2つに限定され、しかもどちらも不正値の検証を行っています。
これで「残高は負にならない」という不変条件は、クラス内部だけで完結して守られるわけです。

従来はアンダースコアを付けた_balanceという命名で「外部から触らないでほしい」という意思表示をするのが一般的でした。
しかし、それは規約にすぎません。
#による保護は実行時に強制され、違反はエラーとして即座に検出される点が決定的な違いです。

ゲッターとセッターで意図した変更だけを許可する設計

プライベートフィールドで状態を隠しただけでは、外部は値を読むことすらできません。
そこで必要になるのが、読み書きの窓口としてのゲッターとセッターです。
ここで押さえておきたいのは、アクセサは単なる窓口ではなく、検証ロジックを内蔵できる関数であるという点です。

class Temperature {
  #celsius;

  constructor(celsius) {
    this.celsius = celsius; // セッターを経由して検証を一元化
  }

  get celsius() {
    return this.#celsius;
  }

  set celsius(value) {
    if (!Number.isFinite(value)) {
      throw new TypeError('数値を指定してください');
    }
    if (value < -273.15) {
      throw new RangeError('絶対零度未満の温度は存在しません');
    }
    this.#celsius = value;
  }

  get fahrenheit() {
    return this.#celsius * 9 / 5 + 32;
  }
}

const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77

temp.celsius = 30;   // 検証を通過するため更新される
temp.celsius = -300; // RangeError

celsiusへの代入はすべてセッターを経由するため、不正な値が内部に入り込む経路は原理的に存在しません。
コンストラクタからもセッターを呼ぶことで、検証ロジックの重複も避けられます。

さらに注目したいのがfahrenheitです。
内部状態から導出できる値は、フィールドとして保持せずゲッターで計算して提供するのが得策です。
導出値をわざわざ保持すると、元の値との同期漏れという新たなバグの芽を増やすだけになりかねません。

最後に、3つのアプローチの違いを整理しておきます。

アプローチ 外部からの書き込み 不変条件の保護
公開プロパティ 自由 不可
アンダースコア命名 可能(規約上の了解のみ) 保証されない
#+アクセサ 検証付きの経路のみ 可能

カプセル化は、この後紹介する4つのプラクティスすべての土台になる概念です。
この土台を固めるだけで、状態起因のバグは大幅に減るはずです。

ベストプラクティス2:thisのバインド問題を構造的に回避する書き方

コールバック内でthisが想定外の値になる問題と、その回避策をまとめた図解イメージ

冒頭で紹介した「メソッドを取り出した途端にthisが壊れる」問題。
古くから知られる対策は、呼び出し箇所でいちいちbindする、あるいはcallやapplyで明示的にコンテキストを渡すというものです。
しかし、これらは呼び出す側の注意力に依存する対策であり、一箇所でも忘れればバグは再発します。

人間の注意力は信頼できるシステム要件になり得ない——これが私の基本的な立場です。
したがって目指すべきは、どんな呼び出し方をしてもthisが壊れない構造そのものをコードに組み込むことです。
具体的には、次の3つの手法が選択肢になります。

  • コールバックの外殻をアロー関数で包む
  • bindでthisを事前束縛する
  • メソッド自体をクラスフィールド構文で定義する

順に見ていきましょう。

アロー関数でthisを固定してイベントハンドラを安全にする

アロー関数の本質は、記法が短いことではありません。
重要なのは、自身のthisを持たず、外側のスコープのthisをそのまま引き継ぐ(lexical this)という言語仕様です。
定義した場所でthisが確定するため、呼び出し方の影響を一切受けません。
この性質をコールバックに応用します。

class Stopwatch {
  #elapsed = 0;
  #timerId = null;

  start() {
    // アロー関数はstartのスコープのthis(=インスタンス)を引き継ぐ
    this.#timerId = setInterval(() => {
      this.#elapsed += 100;
      console.log(`${this.#elapsed}ms経過`);
    }, 100);
  }

  stop() {
    clearInterval(this.#timerId);
  }
}

const watch = new Stopwatch();
watch.start(); // 100ms経過、200ms経過…とthisが壊れず動き続ける

setIntervalに渡しているのは無名関数ですが、アロー関数であるがゆえにthisはstart実行時のthis、つまりインスタンスを指し続けます。
ここが通常のfunction式だった場合、this.#elapsedはundefinedへの参照になり、実行時エラーで止まります。

イベントリスナーでも考え方は同じです。

class SaveButton {
  constructor(form) {
    form.addEventListener('submit', (event) => {
      this.submit(event); // thisは常にインスタンス
    });
  }

  submit(event) {
    event.preventDefault();
    // 保存処理…
  }
}

コールバックの最外殻をアロー関数で包み、そこから通常のメソッドを呼ぶ構成にすれば、メソッド側はthisを一切意識せずに済みます。
「アロー関数は境界、メソッドは本体」と役割を分けるのが、実務で最も再現性の高いパターンだと考えています。

bindメソッドとクラスフィールド構文の使い分け

コールバックとして渡すのがメソッドそのものである場合、メソッド側のthisをあらかじめ固定しておきたい場面があります。
ここで選択肢になるのが、bindによる事前束縛とクラスフィールド構文です。

bindは、thisを恒久的に固定した新しい関数を返すメソッドです。
従来はコンストラクタで束縛するのが定石でした。

class SearchBox {
  constructor(input) {
    input.addEventListener('input', this.onInput.bind(this));
  }

  onInput(event) {
    console.log(`検索ワード: ${event.target.value}`);
  }
}

またbindは、thisに加えて引数の前置き(部分適用)も行えるため、汎用的な下請けメソッドの挙動を差し替えずに固定したい場面では今も有効です。

一方、クラスフィールド構文を使うと、メソッド定義そのものをアロー関数にできます。

class SearchBox {
  onInput = (event) => {
    console.log(`検索ワード: ${event.target.value}`);
  };

  constructor(input) {
    input.addEventListener('input', this.onInput);
  }
}

クラスフィールドはインスタンスの生成時に評価されるため、onInputはプロトタイプではなくインスタンス自身のプロパティとして、thisが固定された状態で定義されます。
呼び出し側はbindを意識する必要がなく、最初のCounterの例で言えば、const handler = counter.incrementとしても壊れないわけです。

では、どちらを選べばよいのでしょうか。
整理すると次の表のとおりです。

手法 束縛のタイミング 関数の実体の置き場所 向いている場面
アロー関数でラップ コールバック定義時 呼び出し箇所 一時的なコールバック
bindによる事前束縛 コンストラクタ実行時 インスタンス 引数の部分適用もしたい場合
クラスフィールド構文 インスタンス生成時 各インスタンス コールバック渡しが前提のメソッド

1点だけ注意があります。
後者の2つは関数を実体ごとインスタンスに持たせるため、メモリ消費がわずかに増えます。
さらに、インスタンス自身のプロパティになる関係上、プロトタイプを差し替える形でのテスト時のモック化や、サブクラスによるオーバーライドが意図どおりに働かないことがあります。
テスト戦略や継承構造と組み合わせる際は、この特性を頭に入れておいてください。

いずれの手法も、「呼び出し側の注意」を「定義側の構造」に置き換えられる点で共通しています。
this問題は、バグが顕在化する場所と原因のコードが離れがちな、典型的な遠隔被害型の不具合です。
だからこそ、原因を書いた時点で構造的に封じ込めておく価値が高いのです。

ベストプラクティス3:コンストラクタで不正な状態のインスタンスを作らない

コンストラクタでのバリデーションにより不正なインスタンス生成を防ぐ設計を表した図解イメージ

インスタンスにとって、コンストラクタは誕生の瞬間です。
そして誕生の瞬間に不正な状態を許したクラスは、その後の生涯でずっとその不正と付き合い続けることになります。
属性チェックを各メソッドに散りばめ、nullかどうかを呼び出し側にいちいち確認させる——そうした防御的なコードが増殖する背景には、たいてい「初期化時に検証しなかった」という原因が潜んでいます。

プログラミングの原則にFail Fast(早く失敗させる)という考え方があります。
不正な入力は、発見できるだけ早い段階でエラーに落とすのが結果的に最も安価だ、という発想です。
その最初で最大の適用場所が、コンストラクタだと考えています。

初期化時のバリデーションで不正な値を検出する

具体的な例として、開始日と終了日を持つDateRangeクラスを見てみましょう。
このクラスの不変条件は「開始日は終了日以前である」です。
これを誕生時に検証します。

class DateRange {
  #start;
  #end;

  constructor(start, end) {
    const s = new Date(start);
    const e = new Date(end);

    if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) {
      throw new TypeError('有効な日付を指定してください');
    }
    if (s > e) {
      throw new RangeError('開始日は終了日以前である必要があります');
    }

    this.#start = s;
    this.#end = e;
  }

  get durationDays() {
    return Math.floor((this.#end - this.#start) / 86400000);
  }
}

const range = new DateRange('2024-04-01', '2024-04-15');
console.log(range.durationDays); // 14

new DateRange('2024-04-15', '2024-04-01'); // RangeError

検証を省略した場合を想像すると、この厳しさの価値が見えてきます。
開始日が終了日を超えたインスタンスが生成を許されると、durationDaysは負の値を返し、期間の重なり判定は意味不明な結果を返します。
そしてエラーは、生成場所から離れた画面描画や集計処理で初めて表面化するでしょう。
原因と発見箇所が離れるほど、デバッグコストは非線形に増大します。
誕生時に落とす一行の例外は、この将来コストを一括で前倒しに支払う保険です。

ファクトリメソッドで安全な生成フローを設計する

コンストラクタの検証を徹底すると、次のような疑問が湧きます。
APIレスポンスのように生の文字列が届くかもしれない場合、呼び出し側は毎回try-catchを書くべきなのでしょうか。
nullになりうる値を受け取ったら?実はここには、コンストラクタの構文上の制約があります。
JavaScriptのコンストラクタは必ず新しいインスタンスを返すか、例外を投げるかの二択で、nullの代わりにnullを返す、という緩やかな契約を表現できません。

この課題を解決するのが、staticメソッドによるファクトリです。
例を見てみます。

class Email {
  #value;

  constructor(value) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      throw new TypeError('メールアドレスの形式が不正です');
    }
    this.#value = value;
  }

  get value() {
    return this.#value;
  }

  // 生の入力値を正規化してから生成する
  static parse(raw) {
    return new Email(String(raw ?? '').trim().toLowerCase());
  }

  // 失敗を例外ではなくnullで表現したい呼び出し側向け
  static tryParse(raw) {
    try {
      return Email.parse(raw);
    } catch {
      return null;
    }
  }
}

const email = Email.parse('  User@Example.com ');
console.log(email.value); // user@example.com

コンストラクタは形式検証という単一の責務に専念し、正規化や失敗時の扱いというバリエーションはファクトリ側に分離されました。
重要なのは、どんな生成経路を通っても、最終的に同じ検証を通過するという構造です。
検証ロジックが一箇所に集約されるため、ルール変更時の修正漏れも起きません。

ファクトリの命名には、意味を持たせる慣習があります。
整理しておきましょう。

命名 意味・契約 使用場面
parse 生の入力を変換・検証して生成 文字列からの生成
from〜 既存のオブジェクトから変換 APIレスポンスやDB行から生成
tryParse 失敗をnullで返す 不正値が許容される入力境界

なお、意図しない直接生成を排除したい場合、new.targetを参照してコンストラクタの直接呼び出しを検知する手法もあります。
ただし、そこまでの厳格さを要する場面は限られるため、まずは「コンストラクタは最も厳しい門番にする」という規律から徹底するのが現実的です。

生成時に不正を封じるこのプラクティスは、前章までのカプセル化と表裃一体です。
状態を守る経路を限定し、その入口で検証する。
この二段構えが揃って初めて、クラスの不変条件は完全に担保されます。

ベストプラクティス4:継承よりも合成を優先する柔軟なクラス設計

深い継承による密結合と小さな部品を組み合わせる合成の設計を対比した図解イメージ

第1章で「ペンギンが飛ぶ」例を紹介しましたが、あれは氷山の一角にすぎません。
デザインパターンの原典として知られるGoFの書籍は、冒頭の設計原則のひとつとして継承よりも合成を優先する(Favor composition over inheritance)という格言を掲げています。
発表から30年近く経った今なお繰り返される失敗の筆頭が、継承の濫用です。

なぜ、そこまで継承が警戒されるのでしょうか。
理由は明快で、継承はオブジェクト指向の中で最も強い結合を生むメカニズムだからです。
カプセル化で隠したはずの内部実装が、extendsの一行で子クラスに露出する。
さらに子クラスのオーバーライドは、親クラスが「自分のメソッドがどう使われるか」について持つ前提さえ壊せてしまいます。
まずは落とし穴を具体的に確認し、そのうえで代替手段となる合成と委譲を設計していきましょう。

継承の落とし穴:親クラスの変更が波及する密結合のリスク

まず名前を付けておきたい問題があります。
脆弱な基底クラス(Fragile Base Class)問題です。
子クラスは親クラスの公開メソッドだけでなく、その裏側にある実装の詳細——メソッド同士がどう呼び合っているかという呼び出しグラフ——に暗黙に依存します。
親クラスが公開インターフェースを一切変えずにリファクタリングしただけで子クラスが壊れる。
「親は触っていないのに、なぜ?」という不可解なバグ報告の裏では、大抵この問題が動いています。

一方で、子クラス側から親の前提を壊すケースもあります。
オブジェクト指向の文献で定番の、長方形と正方形の例を見てみましょう。

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(value) {
    this.width = value;
  }

  setHeight(value) {
    this.height = value;
  }

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

class Square extends Rectangle {
  // 正方形の不変条件「全辺が等しい」を守るための、真っ当なオーバーライド
  setWidth(value) {
    this.width = value;
    this.height = value;
  }

  setHeight(value) {
    this.width = value;
    this.height = value;
  }
}

function makeLandscape(rect) {
  rect.setWidth(400);
  rect.setHeight(300);
  return rect.area;
}

console.log(makeLandscape(new Rectangle(100, 100))); // 120000(400×300)
console.log(makeLandscape(new Square(100)));         // 90000(300×300)

「正方形は長方形の特殊ケース」という数学的な事実に基づけば、この継承は至極まともに見えます。
しかしRectangleを前提に書かれたmakeLandscapeに渡した瞬間、「幅400・高さ300に設定する」という契約が例外も投げずに黙って破られます。
第1章で触れたリスコフの置換原則——親クラスを参照するコードは子クラスでも正しく動くという前提——の崩壊です。
しかもこの例が示すのは、子クラスの実装に落ち度がなくても破綻しうるという点。
つまり原因は書き手ではなく、継承という構造そのものにあります。

とはいえ、継承が妥当な候補になる場面は存在します。
目安は次のとおりです。

  • 親クラスから受け継ぐのが実装ではなく契約(インターフェース)の再利用である
  • 子クラスが親の内部の呼び出しグラフに依存しない
  • 親子が同じチームで管理され、変更の影響を同時に検証できる

「〜である(is-a)」と言えるから継承、では判断材料として不十分です。
先に「〜を持つ(has-a)」で表現できないかを問う。
その具体的な手法が、次のテーマです。

合成と委譲で疎結合な実装を実現する方法

合成とは、機能を小さなクラス(部品)に分割し、それらをフィールドとして保持する設計です。
そして自分の仕事の一部を保持した部品に任せることを委譲と呼びます。

ありがちな失敗シーンで比較してみます。
CSV出力クラスに「クォート規則の違い」と「出力時のエンコードの違い」という2軸のバリエーションが発生した場合、継承で表現するとクラス数は掛け算で増えます。
CsvExporter、StrictCsvExporter、Utf8CsvExporter、StrictUtf8CsvExporter…。
軸が増えるたびに爆発し続けるわけです。
合成なら、この掛け算を足し算に変換できます。

class CsvEscaper {
  escape(value) {
    const str = String(value);
    return /[",\n]/.test(str) ? `"${str.replaceAll('"', '""')}"` : str;
  }
}

class IdentityEncoder {
  encode(text) {
    return text;
  }
}

class Utf8Encoder {
  encode(text) {
    return new TextEncoder().encode(text);
  }
}

class CsvExporter {
  #escaper;
  #encoder;

  constructor(escaper = new CsvEscaper(), encoder = new IdentityEncoder()) {
    this.#escaper = escaper;
    this.#encoder = encoder;
  }

  export(rows) {
    const text = rows
      .map(row => row.map(v => this.#escaper.escape(v)).join(','))
      .join('\n');
    return this.#encoder.encode(text);
  }
}

// 標準の組み合わせ
const plain = new CsvExporter();

// ダウンロード処理向けに、UTF-8のバイト列で出力する
const forDownload = new CsvExporter(new CsvEscaper(), new Utf8Encoder());

CsvExporterは整形もエンコードも自分では行いません。
それぞれを部品に委譲しているだけです。
この構造の優位性を、バリエーションの組み合わせ数で整理しましょう。

アプローチ クラス数の増え方 変更時の影響範囲
継承 掛け算(m×n) 階層全体に波及
合成+委譲 足し算(m+n) 差し替える部品のみ

バリエーションが3軸と4軸になった時点で、継承は12クラス、合成は7クラス。
差は開く一方です。
さらに重要なのは、部品同士が互いの内部を知らないため、CsvEscaperの修正がUtf8Encoderへ影響する経路が構造的に存在しないことです。
第1章のCartクラスで問題にした「離れた場所に飛び火するバグ」が、そもそも起きえない設計になっています。

この仕組みを機能させる前提が、部品が同じ契約を満たすことです。
Utf8Encoderも今後追加されるどんなエンコーダも、encode(text)という同じシグネチャを持つ。
JavaScriptはダックタイピングのため契約は暗黙でも成立しますが、これは次章で扱うTypeScriptのinterfaceで明示的に宣言でき、さらに安全になります。

実践的な指針を一つ加えるなら、こうです。
私の経験則では、継承が必要だと思った設計の大半は、小さな部品への分解でより良い形に書き換えられました。
迷ったら合成を選び、継承は「明確にis-aが成立し、かつ契約の再利用である」と確認できたときだけ使う。
この順序を守るだけで、変更に強いクラス群が手に入ります。

なお、合成で分解された小さな部品は、状態を持たない、あるいは状態を固定したいという要望との相性が抜群です。
最後のベストプラクティスである不変性に、うってつけの構造といえるでしょう。

ベストプラクティス5:不変性を意識した予測可能なクラスを設計する

外部から変更されない不変なクラス設計を示す図解。状態を保護し予測可能な動作を実現する様子を描いたイメージ

ここまでの4つのプラクティスを振り返ると、すべて状態への書き込み経路を制御するという共通の論理で貫かれていました。
カプセル化は経路を限定し、コンストラクタは入口で検証し、合成は状態を持つ場所を小さく分散させました。
そしてその論理の帰結が、書き込み経路をゼロにするという発想、すなわち不変性です。

可変なオブジェクトがなぜバグの源になるのか。
それは、状態が時間とともに変化するため、コードの正しさを検証するには「いつ、どこで、誰が変更したか」という時間軸まで追跡する必要があるからです。
一方、不変なオブジェクトは一度生成したら二度と変わりません。
ある時点で確認した値は、永遠にそのままです。
この性質が特に威力を発揮するのが、次のような場面です。

  • 複数のモジュールで同じインスタンスを共有している場合
  • 非同期処理でコールバックの実行順序が読めない場合
  • Reactのように「前回と同一なら再描画しない」最適化を行うUIフレームワークを使う場合

では、不変なクラスは具体的にどう設計するのか。
2つの段階に分けて見ていきましょう。

状態を変更せず新しいインスタンスを返すメソッドの作り方

不変クラス設計の核心は、thisの状態を書き換えるメソッドを排除し、代わりに新しいインスタンスを返すメソッドを提供することです。
金額を表すMoneyクラスで確認します。

class Money {
  #amount;
  #currency;

  constructor(amount, currency) {
    this.#amount = amount;
    this.#currency = currency;
  }

  get amount() {
    return this.#amount;
  }

  get currency() {
    return this.#currency;
  }

  add(other) {
    if (!(other instanceof Money) || this.#currency !== other.#currency) {
      throw new TypeError('異なる通貨は加算できません');
    }
    return new Money(this.#amount + other.#amount, this.#currency);
  }

  multiply(rate) {
    return new Money(this.#amount * rate, this.#currency);
  }
}

const price = new Money(1000, 'JPY');
const total = price.multiply(3).add(new Money(200, 'JPY'));

console.log(price.amount); // 1000(元の値は不変)
console.log(total.amount); // 3200

addとmultiplyは、thisの内部状態を一切書き換えず、計算結果を新しいMoneyインスタンスとして返します。
このためpriceは、何度メソッドを呼ばれても1000円のまま。
しかもmultiply(3).add(...)のように、メソッドチェーンで処理を自然につなげられます。

この設計の予測可能性が際立つのは、インスタンスを引数として渡す場面です。
ある関数にpriceを渡したあと、「内部で書き換えていないだろうか」とコードを遡って確認する必要はもうありません。
書き換わる構造そのものが存在しないからです。

コストについても公平に論じておきます。
操作のたびに新インスタンスを生成するため、メモリ確保の回数は増えます。
ただし現代のJSエンジンは短命な小オブジェクトの生成と回収に高度に最適化されており、実務で問題になることはまれです。
まず正しさを選び、遅さが計測で証明された箇所だけ最適化する——この順序を逆にしないことが重要です。

Object.freezeとreadonlyで意図しない変更を防ぐ

「プライベートフィールドで守れば十分では?」という疑問はもっともです。
しかし#による保護は「クラスの外部からの書き込み」を防ぐだけで、クラスが外部へ返したオブジェクトの中身までは守りません。

よくある事故がこれです。
内部の配列をそのまま返すゲッターを用意すると、受け取った側はその配列に自由にpushやspliceを実行でき、内部状態は事実上剥き出しになります。
対策は、防御的コピーとfreezeの組み合わせです。

class Order {
  #items;

  constructor(items) {
    // 受け取った配列をコピーして凍結して保持する
    this.#items = Object.freeze([...items]);
  }

  get items() {
    return this.#items; // 凍結済みなのでゲッター経由でも変更できない
  }
}

const order = new Order(['book', 'pen']);
order.items.push('eraser'); // TypeError(モジュールはstrictモードのため例外が発生)
console.log(order.items); // ['book', 'pen']

ポイントは2つです。
1つ目は[...items]による防御的コピー。
呼び出し元の元配列と参照を共有しないことで、生成後の変更が両側に波及する事態を断ちます。
2つ目はObject.freezeによる凍結で、これによりゲッター経由で取得した配列も変更不能になります。

ただし、freezeには重要な注意点があります。
凍結されるのは指定されたオブジェクト自身のみで、プロパティが参照する内側のオブジェクトは凍結されない、シャロー(浅い)動作だという点です。
ネストした構造ごと守るには、再帰的に適用します。

function deepFreeze(obj) {
  Object.freeze(obj);
  for (const value of Object.values(obj)) {
    if (value !== null && typeof value === 'object') {
      deepFreeze(value);
    }
  }
  return obj;
}

ここまでの保護手段を、守りの深さで整理しておきましょう。

手法 保護の範囲 主な用途
プライベートフィールド(#) クラス境界を越える書き込み 内部状態の隠蔽
防御的コピー+freeze 返却するオブジェクト1層 内部コレクションの漏出防止
deepFreeze ネストした構造全体 設定値など複合データの保護

なお、TypeScriptにはreadonly修飾子があり、変更操作をコンパイル時に検出できます。
実行時強制のfreezeと型レベルのreadonlyは補完関係にあり、その使い分けは次章のテーマです。

最後に、可変と不変の設計を対比してまとめます。

観点 可変な設計 不変な設計
正しさの検証 時間軸の追跡が必要 現時点の確認だけで完結
インスタンス共有時 書き換えの飛び火が起きうる リスクなし
主なコスト 変更は安いが追跡が高くつく 生成コストが増加

ここまでで、5つのベストプラクティスがすべて揃いました。
カプセル化、this対策、生成時の検証、合成、不変性。
いずれも「呼び出し側の注意」を「クラス側の構造」に置き換えるという、同じ思想に支えられた手法です。
次章では、これらの設計にTypeScriptの静的な型システムを重ね、実行前にバグを検出する仕組みをさらに強化していきます。

TypeScriptを組み合わせてクラスの型安全をさらに高める

TypeScriptの型システムでJavaScriptクラスの安全性を高める様子を表した図解イメージ

これまでに紹介した5つのプラクティスには、ある共通点があります。
それは、防御がすべて実行時に成立しているという点です。
プライベートフィールドへの不正アクセスも、セッターを通過した不正値も、コンストラクタで投げられる例外も、検出されるのはコードが実際に動いたあとです。

TypeScriptは、この防御ラインを実行前に引き上げます。
静的型付けの本質は、正しさのうち型で表現できる性質——値の種類、nullの可否、変更の可否——を、コンパイル時の機械的な検証に委ねることにあります。
人間のレビューもテストも労力が有限ですが、コンパイラは毎回、漏れなく同じ検証を数秒で実行してくれます。

本章では、とくに効果が大きい2つの活用方法を解説します。
null安全の確保と、可視性・不変性の明示です。
前提として、tsconfig.jsonでstrictオプションを有効にしておくことを推奨します。
以下の話は、その効果が最大化される構成です。

型注釈と静的型付けでnull安全を確保する

null安全の要となるのがstrictNullChecksです。
このオプションを有効にすると、nullとundefinedは、明示的に許可した型以外へ代入できなくなります。
見方を変えれば「nullが流れ込む経路を型システムで限定する仕組み」であり、第2章で論じたカプセル化の思想を値の世界で実現したものと言えます。

下書き記事を表すDraftクラスで確認してみましょう。
タイトルは設定されるまでnullを許容します。

class Draft {
  #title: string | null = null;

  setTitle(title: string): void {
    if (title.trim() === '') {
      throw new Error('タイトルを空にすることはできません');
    }
    this.#title = title;
  }

  get title(): string {
    return this.#title; // エラー: string | null は string に代入できません
  }
}

ゲッターの返却値の型はstringと宣言したのに、#titleの型はstring | nullです。
この不一致はコンパイルの段階でエラーとして報告されます。
JavaScriptであれば、タイトル未設定のままtitleを読んだコードが動いた瞬間にundefinedが外へ漏れ、呼び出し先で初めてTypeErrorとして発覚したはずです。
型注釈は、この遠隔被害を発生源で遮断します。

修正は、nullのケースを潰すことです。
TypeScriptの制御フロー解析はif文による絞り込みを追跡するため、nullチェックの後は型が自動的に縮まります。

get title(): string {
  if (this.#title === null) {
    throw new Error('タイトルが未設定です');
  }
  return this.#title; // ここでは string に絞り込まれている
}

注目すべきは、未設定時にどう振る舞うかという設計判断を、ゲッターの中に明示的に書かざるを得なくなる点です。

  • undefinedをそのまま返す
  • 例外を投げる
  • 代替値を返す

JavaScriptでは後回しにできるこの判断を、TypeScriptはコンパイルエラーという形で問いただしてきます。
型は単なる検証器ではなく、設計への質問者でもあるわけです。

なお、this.#title!という非nullアサーションでエラーを黙らせることも技術的には可能ですが、これはコンパイラを黙らせているだけで実態のnullは残るため、避けるべき抜け道です。
エラーが出たら潰す。
この規律がnull安全のすべてです。

privateとreadonlyで可視性と不変性を明示する

TypeScriptには、可視性を制御するprivateと、再代入を禁止するreadonlyという修飾子があります。
両者ともコンパイル時にのみ機能し、実行時のコードには残らない点が特徴です。
第2章で紹介した#フィールドとの違いは、この強制タイミングにあります。

請求書を表すInvoiceクラスで確認します。

interface InvoiceItem {
  readonly name: string;
  readonly amount: number;
}

class Invoice {
  constructor(
    public readonly invoiceId: string,
    public readonly issuedAt: Date,
    private readonly items: readonly InvoiceItem[],
  ) {}

  get total(): number {
    return this.items.reduce((sum, item) => sum + item.amount, 0);
  }
}

コンストラクタ引数に修飾子を付けると、対応するフィールドの宣言と代入が自動生成されます(パラメータプロパティ)
宣言が1箇所に集約され、invoiceIdやissuedAtが誤って再代入される余地はありません。
さらにitemsの型readonly InvoiceItem[]には、要素の追加や削除を行うpushやspliceがそもそも定義されていません。
前章でfreezeと防御的コピーで守った内部コレクションが、型のレベルでも守られているわけです。
実行時のfreezeと型のreadonlyは、どちらか一方でなく両方を張って初めて完全になる、補完関係の防壁だと考えています。

このクラスに対する誤った操作は、すべてコンパイルで止まります。

const invoice = new Invoice('INV-001', new Date(), [
  { name: '設計作業', amount: 50000 },
]);

invoice.invoiceId = 'INV-999'; // エラー: readonly への再代入
invoice.items.push({ name: 'X', amount: 0 }); // エラー: private へのアクセスかつpush不可

最後に、2種類の保護手段を強制の観点から整理しておきます。

手段 強制タイミング 実行時の状態
#フィールド(JS標準) 実行時 存在し、アクセスで例外が発生する
private(TypeScript) コンパイル時 型消去され、存在しない
readonly(TypeScript) コンパイル時 存在するが、実行時は再代入可能

「実行時は存在しない」ことは一見弱点に見えますが、私の実務感ではむしろ合理的な使い分けが可能になります。
インスタンスごとに隠しプロパティを持つ#フィールドは生成コストがわずかに増えるため、境界を越えた書き込みを物理的に防ぎたいものだけを#に、純粋な内部実装の詳細はprivateに。
コストとリスクの釣り合いで選ぶのが、型システムとの賢い付き合い方です。

静的な型は、ここまで構築してきたクラス設計を「動くたびに検証される仕様」へと変えます。
ただし、型が保証するのはデータ構造の整合性だけです。
状態遷移が仕様どおりに動くかという振る舞いの正しさは、依然として別の検証が必要です。
次章では、その最後の砦であるユニットテストを取り上げます。

ユニットテストでクラスのバグを開発段階で検出する

クラスのユニットテストを実行しバグを早期に検出する開発フローを表した図解イメージ

いよいよ最後の防御層、ユニットテストです。
これまでに構築してきた防御手段を、検証のタイミングと守る対象で整理すると、次の3層構造になります。

防御層 検証タイミング 守る対象
カプセル化と実行時検証 実行時 不変条件
TypeScriptの型システム コンパイル時 データ構造の整合性
ユニットテスト 開発中とCI 振る舞いの正しさ

ここで、型システムの限界を正確に認識しておきましょう。
TypeScriptは「完了済みのタスクを再着手できない」といった業務ルール——遷移の順序関係——を、通常のクラス設計の中では表現できません。
union型は状態の種類を縛れても、状態がたどる並び順までは縛らないからです。
つまり型が保証するのはあくまで構造であり、時間とともに展開する振る舞いは人間の書いたロジック次第です。
このギャップを埋めるのがユニットテストの役割です。

テストにはもうひとつ、リグレッション(退行)の防止という重要な価値があります。
ここまでのプラクティスで堅牢なクラスを手に入れても、その後の改修で壊さない保証はどこにもありません。
全テストが揃っていれば、改修後にすべて通過することを「振る舞いが保存された」ことの根拠にできます。
プルリクエストごとにCIで全テストを回す運用は、バグを開発段階で止める最後の関門として、定番中の定番です。

実行環境にはVitestを採用します。
Jestとほぼ互換のAPIを持ちながら高速で、TypeScriptも追加設定なしで扱えるため、現時点での第一選択と考えています。

状態遷移を検証するテストケースの設計

状態を持つクラスのテストで最初に設計すべきは、状態遷移表です。
まず例として、タスク管理の状態機械を持つTaskクラスを用意しました。

type TaskStatus = 'todo' | 'in_progress' | 'done';

class Task {
  #status: TaskStatus = 'todo';

  get status(): TaskStatus {
    return this.#status;
  }

  start(): void {
    if (this.#status !== 'todo') {
      throw new Error(`着手できるのは未着手のタスクだけです(現在: ${this.#status})`);
    }
    this.#status = 'in_progress';
  }

  complete(): void {
    if (this.#status !== 'in_progress') {
      throw new Error(`完了できるのは進行中のタスクだけです(現在: ${this.#status})`);
    }
    this.#status = 'done';
  }
}

このクラスは状態3つ、遷移を起こすメソッド2つです。
取りうる組み合わせは3×2の6通りであり、すべてを表に並べると次のようになります。

現在の状態 start() complete()
todo in_progressへ遷移 例外
in_progress 例外 doneへ遷移
done 例外 例外

重要なのは、この表の全マスをテストケースに変換すれば網羅が機械的に達成できる、という点です。
テストケースの洗い出しは勘ではなく手続きになります。
「テスト何件書けば十分か」という問いへの答えは、状態遷移表のマスの数なのです。
変換した結果がこちらです。

import { describe, it, expect } from 'vitest';
import { Task } from './task';

describe('Taskの状態遷移', () => {
  it('生成直後の状態はtodoである', () => {
    expect(new Task().status).toBe('todo');
  });

  it('todoからstartするとin_progressになり、completeするとdoneになる', () => {
    const task = new Task();
    task.start();
    expect(task.status).toBe('in_progress'); // todo × start
    task.complete();
    expect(task.status).toBe('done');        // in_progress × complete
  });

  it('未着手のタスクはcompleteできない', () => {
    const task = new Task();
    expect(() => task.complete()).toThrow('完了できるのは進行中のタスクだけです');
  });

  it('進行中のタスクは再びstartできない', () => {
    const task = new Task();
    task.start();
    expect(() => task.start()).toThrow('着手できるのは未着手のタスクだけです');
  });

  it('完了済みのタスクはstartもcompleteもできない', () => {
    const task = new Task();
    task.start();
    task.complete();
    expect(() => task.start()).toThrow();
    expect(() => task.complete()).toThrow();
  });
});

各テストは、準備(Arrange)・実行(Act)・検証(Assert)の3段構成で書かれています。
そして1テストにつき1つの振る舞いだけを検証し、インスタンスは毎回新しく生成して、テスト同士が状態を共有しない構成にしています。
実行順序を入れ替えても結果が変わらない——これが安定して動くテストスイートの条件です。

注目していただきたいのが、テストがすべて公開API経由で行われている点です。
#statusはテストから参照できません。
これは制約ではなく利点です。
テストが検証しているのは内部実装ではなく、status・start・completeというクラスの契約だからです。
したがって、内部で#statusを#phaseに改名してもテストは一切壊れません。
第2章で論じたカプセル化が、ここでテストの保守性という形で回収されるわけです。
実装の詳細に依存したテストは、リファクタリングのたびに大量に壊れ、やがて削除されていきます。

例外の検証についても補足します。
第3章で「コンストラクタでFail Fastせよ」と述べましたが、throwされる例外をtoThrowで検証して初めて、そのルールは仕様として固定されます。
メッセージは部分一致に留めるのが実務的です。
文言まで完全一致させると、表現の微修正だけでテストが壊れ、警告の価値が薄れるからです。

最後に、この遷移表にはテスト以外の効果もあります。
仕様変更で遷移ルールが変わるとき、変更すべきは表と、表から機械的に導かれたテストです。
「どのテストを直すべきか」という漏れの心配ごと、構造から消えます。
テストコードは、実行可能な仕様書として機能しているのです。

これで、バグを多層的に防ぐ仕組みがすべて揃いました。
次章では、5つのベストプラクティスを総括し、日常の開発に落とし込むための指針をまとめます。

まとめ:JavaScriptのクラス設計を見直してバグの少ないコードを書こう

JavaScriptのクラス設計における5つのベストプラクティスをまとめたチェックリストのイメージ

本記事では、JavaScriptのクラス実装でバグを激減させるための5つのベストプラクティスを、典型的な失敗パターンとともに解説してきました。
最後に全体を振り返り、日常の開発に落とし込むための指針を整理します。

まず、5つのプラクティスを一覧にまとめます。

プラクティス 対策する失敗パターン 鍵となる機能・手法
カプセル化の徹底 状態の不整合 #フィールド、アクセサ
this問題の構造的回避 実行時例外、undefined参照 アロー関数、クラスフィールド構文
生成時の検証 不正な状態のインスタンス コンストラクタのバリデーション、ファクトリメソッド
継承より合成 変更の波及、密結合 部品化、委譲
不変性の設計 共有インスタンスの意図しない変更 新インスタンスの返却、Object.freeze

改めて強調したいのは、これらが個別の小手先のテクニックではなく、ひとつの思想から導かれた一貫した戦略だという点です。
その思想とは、「バグの防止を、呼び出し側の注意ではなく、クラス側の構造に任せる」こと。
this問題にしても、公開プロパティにしても、事故が起きる原因の多くは「正しい使い方を利用者が覚えておく」という暗黙の契約にあります。
ヒューマンエラーは必ず起こるという前提に立ったとき、正しい使い方しかできない構造そのものを提供するのが、堅牢なクラス設計の本質です。
第6章で見たとおり、TypeScriptの型システムはこの戦略をコンパイル時にまで拡張し、ユニットテストは振る舞いという最後の領域を検証可能にしました。

では、実際の開発では何から始めるべきでしょうか。
すべてを一度に適用しようとすると、変更が膨らんで途中で頓挫しがちです。
おすすめは、次の順序での段階的な導入です。

  1. まず公開しているプロパティを見直し、#フィールドとアクセサで書き込み経路を限定する
  2. コンストラクタにバリデーションを加え、不正なインスタンスの生成を防ぐ
  3. 新規クラスから合成と不変性を取り入れ、既存コードは修正の機会に合わせて移行する
  4. 型安全とテストは、CIを整備したうえでプロジェクト全体に広げる

既存のコードベースが大きい場合、全クラスの書き換えは現実的ではありません。
効果が高く、影響範囲が読みやすい箇所——頻繁にバグ報告が上がるクラスや、複数モジュールから参照される中心的なクラス——から着手するのが、費用対効果の観点から最善です。
設計の改善は一度きりの作業ではなく、プロダクトの成長に合わせて続く投資だと考えています。

最後にひとつ、問いかけを残します。
あなたの書いたクラスは、半年後の自分や同僚が読んで「意図がひと目でわかる」でしょうか。
外から触れるべきでない状態が守られ、不正な値は生まれる前に拒まれ、変更の影響範囲が局所に収まるコード。
それらは、その場の実装速度をわずかに犠牲にする代わりに、デバッグと改修という将来のコストを大幅に削減します。
バグの少ないコードは、才能ではなく規律から生まれます。
本記事のプラクティスが、その規律をあなたの日常のコードに落とし込むための一助となれば幸いです。

コメント

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