TypeScript | 関数・クラス・ジェネリクス:クラス設計 – 実務でのクラス設計判断

TypeScript TypeScript
スポンサーリンク

ゴール:「この処理、本当にクラスにすべき?」を自分で判断できるようになる

実務で一番大事なのは、
「クラスの書き方」よりも、

「そもそも、ここはクラスにすべきか?」

を判断できることです。

TypeScript は関数・オブジェクト・クラス・ジェネリクス、何でも書けてしまうので、
逆に「なんでもクラス」にすると、すぐにコードが重たくなります。

ここでは、実務で僕が本当に使っている視点に絞って、
クラス設計の判断軸をかみ砕いて話していきます。

まず決めるべきこと:「クラスを使うか、関数で済ませるか」

クラスにする前に必ず自分に聞いてほしいこと

いきなりクラスを設計する前に、
まず自分にこう問いかけてみてください。

「これは“状態を持ったまま、何度も振る舞いを呼び出す”ものか?」

もし答えが「いいえ」なら、
それはたぶんクラスではなく「ただの関数」で十分です。

例えば、こういうのは関数でいいです。

function calcTax(price: number): number {
  return Math.floor(price * 0.1);
}
TypeScript

状態もないし、
「税計算クラス」をわざわざ作る意味は薄いです。

逆に、こういうのはクラスに向いています。

class ShoppingCart {
  private items: { name: string; price: number }[] = [];

  addItem(item: { name: string; price: number }) {
    this.items.push(item);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}
TypeScript

ここでは、

  • カートという「状態」を持ち続ける
  • その状態に対して「追加」「合計計算」という振る舞いを提供する

という構造になっていて、
クラスにする意味がはっきりあります。

大事なのは、

「状態+振る舞いのセットとして扱いたいか?」

という視点です。

「データの入れ物」だけなら type/interface で十分

もうひとつよくあるのが、
「ただのデータ構造」をクラスにしてしまうパターンです。

例えば、こういうのはクラスにしなくていいことが多いです。

class User {
  constructor(
    public id: number,
    public name: string,
    public email: string
  ) {}
}
TypeScript

これ、メソッドもなくて、
ただの「ユーザー情報の入れ物」ですよね。

こういう場合は、素直に type や interface で十分です。

interface User {
  id: number;
  name: string;
  email: string;
}
TypeScript

クラスにするのは、

  • その型に「意味のある振る舞い」をくっつけたいとき
  • 生成のルール(バリデーションなど)を中に閉じ込めたいとき

に絞ると、設計がだいぶスッキリします。

実務でよく出る「クラスにするべき場面」

例1:ドメインの“概念”を表したいとき

EC サイトなら「注文」「カート」「商品」、
チャットアプリなら「メッセージ」「ルーム」「ユーザー」など。

こういう「ビジネス上の概念」は、
クラスにするとコードが読みやすくなります。

class Order {
  constructor(
    private items: { name: string; price: number }[],
    private taxRate: number
  ) {}

  getTotal(): number {
    const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
    return Math.floor(subtotal * (1 + this.taxRate));
  }
}
TypeScript

ここでのポイントは、

  • Order という名前だけで「何のことか」が伝わる
  • 合計金額の計算ロジックが、Order の中に閉じている
  • 呼び出し側は「注文に対して total を聞く」という自然な書き方になる

ということです。

実務では、

「ビジネス用語として名前が立っているもの」

は、クラスにすると読みやすくなりやすいです。

例2:ライフサイクルを持つもの(接続・セッションなど)

例えば、API クライアントや WebSocket 接続など。

class ApiClient {
  constructor(private baseUrl: string) {}

  async get(path: string) {
    const res = await fetch(this.baseUrl + path);
    return res.json();
  }
}
TypeScript

こういう「接続」「セッション」「クライアント」は、

  • 設定(baseUrl、トークンなど)を持ち続ける
  • その設定を使って何度もメソッドを呼ぶ

という構造なので、クラスに向いています。

関数だけで書くと、

  • 毎回 baseUrl を渡す必要がある
  • 設定がバラバラに散らばる

といった問題が出やすくなります。

例3:状態をまたいで処理が進むもの(状態機械・ウィザードなど)

例えば、画面のステップが進んでいくウィザード。

class SignupFlow {
  private step: number = 1;

  next() {
    this.step++;
  }

  get currentStep() {
    return this.step;
  }
}
TypeScript

こういう「状態が変化していくもの」は、
クラスにまとめると追いやすくなります。

実務では、

  • フォームの状態管理
  • ステップごとのバリデーション
  • 状態に応じたボタンの活性・非活性

などをクラスに閉じ込めると、
UI 側のコードがかなりスッキリします。

実務での判断軸:「クラスを分ける/まとめる」の基準

判断軸1:責務が1つかどうか(1クラス1役割)

クラス設計で一番壊れやすいのがここです。

例えば、こんなクラスは危険信号です。

class UserService {
  async fetchUser() { /* API 呼び出し */ }
  validateUser() { /* バリデーション */ }
  renderUser() { /* DOM 操作して表示 */ }
}
TypeScript

API 呼び出し・バリデーション・画面描画。
役割がバラバラに混ざっています。

こういうときは、責務ごとに分けた方がいいです。

class UserApi {
  async fetchUser() { /* API 呼び出し */ }
}

class UserValidator {
  validateUser() { /* バリデーション */ }
}

class UserView {
  renderUser() { /* 表示 */ }
}
TypeScript

実務での感覚としては、

「このクラスの説明を一文で言えるか?」

が基準になります。

「ユーザーを取得するクラスです」
「ユーザーを検証するクラスです」

と言えるなら OK。

「ユーザーを取得して検証して画面に出すクラスです」
と言い出したら、もう分けた方がいいです。

判断軸2:テストしやすいかどうか

実務では、テストのしやすさも重要な判断材料です。

例えば、こんなクラス。

class UserService {
  async getUserName(id: number): Promise<string> {
    const res = await fetch(`/users/${id}`);
    const data = await res.json();
    return data.name.toUpperCase();
  }
}
TypeScript

このクラスは、

  • ネットワーク呼び出し
  • データ整形(toUpperCase

が混ざっています。

テストしたいのは本当は「整形ロジック」だけなのに、
毎回 API をモックしないといけなくなります。

これを分けると、テストが楽になります。

class UserApi {
  async fetchUser(id: number) { /* fetch */ }
}

class UserFormatter {
  formatName(name: string): string {
    return name.toUpperCase();
  }
}
TypeScript

実務では、

「このクラスをテストするとき、余計なものをたくさんモックしないといけないなら、責務が多すぎる」

と考えてみてください。

判断軸3:将来の変更に強いかどうか

クラス設計の良し悪しは、
「変更が入ったとき」に露骨に出ます。

例えば、User の表示ルールが変わったときに、

  • 1クラスだけ直せば済む
  • 3〜4クラスにまたがって修正が必要

どちらになりそうか、を想像してみてください。

実務では、

  • 「変わりやすいもの」は分離する
  • 「一緒に変わるもの」は同じクラスにまとめる

という感覚が大事です。

API の仕様は変わるかもしれない。
画面のデザインも変わるかもしれない。
でも、ビジネスルール(例えば「合計金額の計算」)は比較的安定している。

そういう「変わり方の違い」を意識してクラスを分けると、
後からの修正コストがかなり変わります。

実務でありがちな「やりすぎ/やらなさすぎ」パターン

やりすぎパターン:なんでもかんでもクラス

初心者がハマりがちなのがこれです。

  • ただの変換処理をクラスにする
  • 1回しか使わない処理をクラスにする
  • 状態もないのにクラスにする

結果として、

  • ファイル数だけ増える
  • クラス名を考えるのに疲れる
  • どこに何があるか分からなくなる

という状態になります。

「状態を持たない」「ビジネス上の意味も薄い」処理は、
まずは関数で書いてみてください。

クラスは、

「名前を与えたい概念」
「状態を持ち続けるもの」
「振る舞いをまとめたいもの」

に絞るとちょうどいいです。

やらなさすぎパターン:全部関数でベタ書き

逆に、全部関数で書いてしまうパターンもあります。

let items: { name: string; price: number }[] = [];

function addItem(item: { name: string; price: number }) {
  items.push(item);
}

function getTotal() {
  return items.reduce((sum, item) => sum + item.price, 0);
}
TypeScript

最初はこれでも動きますが、

  • カートが2つ必要になったら?
  • 「割引付きカート」と「通常カート」を分けたくなったら?

といったときに、一気に破綻します。

こういうときは、素直にクラスにしてしまった方がいいです。

class ShoppingCart {
  private items: { name: string; price: number }[] = [];

  addItem(item: { name: string; price: number }) {
    this.items.push(item);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}
TypeScript

実務では、

「同じ“種類のもの”を複数持ちたいとき」

は、クラスにするサインだと思ってください。

まとめ:実務でのクラス設計判断を自分の言葉で整理すると

最後に、あなた自身の言葉でこうまとめてみてください。

クラスにするのは、

  • 状態+振る舞いをセットで扱いたいとき
  • ビジネス上の概念(注文・カート・ユーザーなど)を表したいとき
  • ライフサイクルやセッションを持つもの(API クライアントなど)のとき
  • 複数個並べて扱いたい「同じ種類のもの」があるとき

クラスを分けるかどうかは、

  • 「このクラスの説明を一文で言えるか?」
  • 「テストするときに余計なモックが増えすぎないか?」
  • 「将来の変更で一緒に変わるもの同士をまとめているか?」

で判断する。

今書いているコードの中から、
1つだけ「これはクラスにした方がよさそう」「逆にクラスやめて関数でよさそう」
と思う場所を選んで、実際に書き換えてみてください。

その小さな一歩を何度も繰り返すうちに、
「クラス設計の判断」は、ちゃんと自分の感覚として育っていきます。

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