【TypeScript】なぜクラスを使わない開発が増えているのか?オブジェクト指向からの脱却と具体的な代替案

TypeScriptのコードエディタ画面とオブジェクト指向から関数型への移行を示す図 プログラミング言語

近年、TypeScriptのプロジェクトにおいてクラスを用いた設計を採用しないケースが顕著に増えています。
コンピューターサイエンスの観点から見れば、オブジェクト指向プログラミングは堅牢なシステムを構築するための有効なパラダイムです。
しかし、現代のフロントエンド開発やAPIサーバー構築において、クラスの内部状態のカプセル化や継承による階層構造は、しばしば複雑性を招き、バグの温床となります。
特にReactやNode.jsのような関数型のアプローチが強いエコシステムでは、クラスの利用がアンチパターンと見なされることも少なくありません。

本記事では、TypeScriptにおいてクラスを利用しない開発スタイルが支持される論理的背景を紐解き、従来のオブジェクト指向から脱却するための具体的な設計手法を解説します。

  • 状態管理の分離: UIとロジックの関心を分離し、副作用を局所化する手法
  • 純粋関数の活用: 入出力が明確でテスト容易性の高い関数によるドメインロジックの構築
  • データと振る舞いの結合: クラスが持つ「状態」と「メソッド」を分離し、より柔軟な設計を実現する方法

例えば、従来のクラスを用いた以下のようなデータモデルを考えてみます。

class UserAccount {
  constructor(private name: string, private age: number) {}
  isAdult(): boolean {
    return this.age >= 20;
  }
}

このシンプルなモデルであっても、インスタンスが暗黙的な状態を保持するため、関数の呼び出し結果がコンテキストに依存する可能性があります。
本記事では、こうしたクラスベースの実装を、どのように関数と型定義を用いた安全な構造へと置き換えていくかを具体的なコード例とともに提示します。
より保守性が高く、型の恩恵を最大限に活かせるモダンなTypeScript開発の実践に向けて、具体的な代替案を探っていきましょう。

TypeScriptのクラス利用が減少している背景と現状の課題

TypeScriptのコードが表示された画面と衰退していくクラスの概念を表すイメージ

近年、TypeScriptを用いたモダンな開発現場において、クラスベースの設計から離れ、関数や純粋なデータ構造を中心としたアプローチを採用するケースが顕著に増えています。
コンピューターサイエンスの観点から見れば、オブジェクト指向プログラミングは長年にわたり複雑なシステムの構築を支えてきた有効なパラダイムです。
しかし、言語の特性や現代のアーキテクチャの変遷に伴い、TypeScriptにおいてクラスを利用することのデメリットが議論されるようになりました。

最大の背景にあるのは、フロントエンドエコシステムにおける関数型プログラミングの台頭です。
ReactをはじめとするモダンなUIライブラリは、状態を持たない純粋な関数によるコンポーネント設計を基本としています。
ReactのHooks APIが登場したことで、クラスコンポーネントが持っていたライフサイクルメソッドによる複雑な状態管理は不要となり、副作用をより宣言的かつ局所的に扱うことが可能になりました。
この潮流により、クラスの持つ「状態のカプセル化」という利点が、UI開発においては却って副作用の追跡を困難にする要因として認識されるようになりました。

また、Node.jsなどのバックエンド環境においても、クラスの利用はしばしば過剰な抽象化を招きます。
TypeScriptは構造的型付けを採用しており、名前的な階層構造を持つクラスの継承よりも、型エイリアスやインターフェースを用いたダックタイピング的な設計が言語の思想により適合しています。
クラスを用いると、不要なインスタンス化のコストや、thisの束縛に関する曖昧な挙動といったJavaScript特有の落とし穴に直面しがちです。

従来のクラスを用いた設計では、以下のような課題が頻発します。

  • インスタンスの暗黙的な状態管理: メソッドの呼び出し結果が内部状態に依存するため、関数の振る舞いが予測しにくくなります
  • thisの束縛問題: コールバック関数としてメソッドを渡す際、意図しないコンテキストで実行されランタイムエラーを引き起こすリスクがあります
  • 過剰な継承による結合度の上昇: 基底クラスの変更が広範なサブクラスに影響を与え、修正のコストが肥大化します

これらの課題に対するアプローチとして、データと振る舞いを分離する設計が有効です。
例えば、以下のような関数と型定義を用いたアプローチに置き換えることで、状態を持たない安全なコードを記述できます。

type User = {
  readonly id: string;
  readonly name: string;
  readonly age: number;
};
const isAdult = (user: User): boolean => user.age >= 20;
const greet = (user: User): string =>
  isAdult(user) ? `Hello, Mr./Ms. ${user.name}` : `Hi, ${user.name}`;

このように、クラスを用いずともTypeScriptの強力な型システムと純粋関数を組み合わせることで、保守性が高くテストが容易なコードを実現できます。
次節以降では、このようなオブジェクト指向からの脱却がもたらす具体的な設計パターンと、その実践方法について論理的に掘り下げていきます。

オブジェクト指向の限界とTypeScriptにおける設計の複雑化

複雑に絡み合ったクラス図とTypeScriptの型定義が並ぶ画面

オブジェクト指向プログラミングは、大規模なシステム開発において長く重宝されてきましたが、TypeScriptのようなモダンな環境においてはその限界が顕在化しています。
特に、状態のカプセル化と継承という二つの主要な機能が、かえって設計を複雑化させ、開発生産性を阻害する要因となっています。
ここでは、クラスを用いた設計が抱える根本的な課題を論理的に紐解いていきます。

クラスが持つ内部状態によるバグの潜在的リスク

クラスは内部に状態(プロパティ)を保持し、その状態をメソッド経由で変更することを前提としています。
しかし、このミュータブル(可変)な状態こそが、予期せぬバグの最大の温床となります。
あるメソッドを呼び出した際、オブジェクトの内部状態がどのように変化するかは、その時点の状態に強く依存します。
これにより、同じメソッド呼び出しでも異なる結果を返すという非参照透過性が生まれ、コードの推論が極めて困難になります。

例えば、以下のような状態を持つクラスを考えてみます。

class ShoppingCart {
  private items: string[] = [];
  addItem(item: string): void {
    this.items.push(item);
  }
  checkout(): string[] {
    // 決済処理後にカートを空にする
    const purchasedItems = [...this.items];
    this.items = [];
    return purchasedItems;
  }
}

このcheckoutメソッドは、呼び出されるタイミングによって返す値が変わり、さらに内部状態を破壊的に変更します。
非同期処理が絡むと、複数の処理が同時にカートの状態を参照・変更しようとする競合状態が発生し、深刻なバグを引き起こします。
関数型アプローチのように、状態を変更せず新しいオブジェクトを返すイミュータブルな設計に比べ、テスト容易性と安全性が著しく低下するのです。

継承による深い階層構造が生む保守性の低下

オブジェクト指向のもう一つの柱である継承も、TypeScriptにおいてはしばしばアンチパターンとなります。
継承はコードの再利用を目的として使われがちですが、階層が深くなるにつれて「基底クラスの変更がサブクラスに未知の影響を与える」という強い結合を生み出します。
これを「脆弱な基底クラス問題」と呼びます。

さらに、コンポーネント間の関係性において、継承は「is-a」関係を強制します。
しかし、現実のドメインロジックは多様であり、無理に継承階層に当てはめようとすると、本来不要なメソッドをサブクラスに実装しなければならない「インターフェース segregation 原則(ISP)」違反を引き起こします。
例えば、Animalクラスを継承してPenguinクラスを作った場合、fly()メソッドをオーバーライドして例外を投げるような不自然な実装を余儀なくされるケースです。

代わりに、TypeScriptでは継承よりもコンポジション(合成)を用いることが推奨されます。
機能を拡張する際には、クラスを継承するのではなく、必要な機能を持つ独立した関数やオブジェクトを組み合わせる方が、依存関係疎結合となり、変更に強いアーキテクチャを構築できます。
このような背景から、クラスの継承を排除し、関数と型による構成的な設計へと移行する動きが加速しています。

Reactなどのモダンなエコシステムにおける関数型の台頭

ReactのロゴとTypeScriptの関数コンポーネントのコード例

TypeScriptにおけるクラスの衰退を決定づけているのは、Reactをはじめとするモダンなフロントエンドエコシステムのパラダイムシフトです。
かつてReactはReact.createClassやクラスコンポーネントを用いてUIを構築していましたが、現在では関数コンポーネントとHooks APIが圧倒的な主流となっています。
この変化は単なるAPIの更改ではなく、アプリケーション設計における関数型プログラミングの優位性を明確に示しています。

UIというものは本質的に、状態からビューへの純粋な写像として表現できます。
UI = f(state)という考え方です。
関数型アプローチでは、与えられた入力(状態やプロパティ)に対して常に同じ出力(仮想DOM)を返す純粋関数としてコンポーネントを定義します。
これにより、クラスが持っていたthisの束縛問題や複雑なライフサイクルメソッドによる状態管理の煩雑さから完全に解放されます。

例えば、現在のモダンなReact + TypeScriptにおけるコンポーネント設計は以下のように極めてシンプルかつ宣言的です。

import React, { useState } from 'react';
type CounterProps = {
  initialCount: number;
};
export const Counter: React.FC<CounterProps> = ({ initialCount }) => {
  const [count, setCount] = useState(initialCount);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
    </div>
  );
};

この関数コンポーネントには、インスタンスという概念が存在しません。
状態はHooksによって関数のスコープ内に局所化され、コンポーネントの再レンダリング時には常に新しい関数実行コンテキストが生成されます。
これにより、クラスのようにインスタンスの内部状態が意図せず保持され続けるバグを根絶できます。

関数型の台頭はフロントエンドに留まりません。
バックエンドやフルスタック領域でも、以下のようなエコシステムがこの流れを強力に後押ししています。

  • Next.js: サーバーコンポーネントを関数として定義し、クライアントとサーバーの境界を宣言的に扱います
  • tRPC: クラスを継承したコントローラーを定義せず、関数のルーターによって型安全なAPIを構築します
  • Zod: クラスベースのバリデーターではなく、関数チェーンによるスキーマ定義でランタイム型安全性を担保します

これらのツールはすべて、データと振る舞いをクラスにカプセル化するアプローチではなく、データの変換パイプラインを関数として構築するアプローチを採用しています。
コンピューターサイエンスの観点から見ても、状態の不変性と参照透過性を重視する関数型プログラミングは、並行処理の増加や複雑化する非同期処理において非常に堅牢です。
クラスを用いたオブジェクト指向は、依然としてドメイン駆動設計(DDD)の一部などで有効な場面はありますが、モダンなWeb開発の主流となるアーキテクチャにおいては、関数と型による設計がデファクトスタンダードとなっているのです。

関数と型を活用したTypeScriptの具体的な代替設計パターン

TypeScriptのinterfaceとアロー関数を用いたコードの代替設計例

クラスベースのオブジェクト指向から脱却する際、TypeScriptの強力な型システムと関数型プログラミングのパラダイムを組み合わせることで、堅牢かつ保守性の高い設計が可能となります。
ここでは、状態を持たない純粋なデータ構造と、それを扱う関数を組み合わせた具体的な代替設計パターンを解説します。

データと振る舞いの分離による純粋関数の実装

オブジェクト指向では、データ(プロパティ)と振る舞い(メソッド)を単一のクラスにカプセル化します。
一方、関数型アプローチでは、これらを明確に分離します。
データは単純な型エイリアスやインターフェースで定義し、振る舞いは状態を持たない純粋関数として外部に切り出します。

これにより、関数の入出力が型によって厳密に保証され、同じ入力に対して常に同じ出力を返す参照透過性が確保されます。
テスト時にはモックインスタンスを生成する必要がなく、単純なデータオブジェクトを渡すだけで検証可能です。

type Task = {
  readonly id: string;
  readonly title: string;
  readonly completed: boolean;
};
// データを受け取り、新しいデータを返す純粋関数
const toggleTask = (task: Task): Task => ({
  ...task,
  completed: !task.completed,
});

ユニオン型とインターセクション型による柔軟な型表現

クラスの継承階層を用いたポリモーフィズムの代替となるのが、TypeScriptのユニオン型(|)とインターセクション型(&)です。
これらを活用することで、実行時の値に依存しないコンパイル時の型安全な状態遷移を表現できます。

特に判別可能なユニオン(Discriminated Union)は、状態ごとの型を明確に分離し、無効な状態遷移をコンパイルエラーとして検知する強力なパターンです。

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
const renderState = (state: AsyncState<string>): string => {
  switch (state.status) {
    case "idle":
      return "待機中";
    case "loading":
      return "読み込み中";
    case "success":
      return `成功: ${state.data}`;
    case "error":
      return `エラー: ${state.error.message}`;
  }
};

関数合成と高階関数によるロジックの再利用

クラスの継承に代わるロジックの再利用手段として、関数合成と高階関数があります。
小さな純粋関数を組み合わせることで、複雑な処理パイプラインを構築します。
これにより、クラス階層の深掘りによる結合度の上昇を避け、必要な関数だけを組み合わせる柔軟な設計が実現します。

例えば、配列の変換処理を高階関数で連結するパターンは、可読性と再利用性を兼ね備えています。

const filterActive = (tasks: Task[]): Task[] =>
  tasks.filter((task) => !task.completed);
const mapTitles = (tasks: Task[]): string[] =>
  tasks.map((task) => task.title);
// 関数の合成によるロジックの構築
const getActiveTaskTitles = (tasks: Task[]): string[] =>
  mapTitles(filterActive(tasks));

このように、データ構造と関数を分離し、型システムで安全に結びつけるアプローチは、クラスが持つ暗黙的な状態依存を排除し、バグの潜みにくいクリーンなアーキテクチャを構築する鍵となります。

モジュールシステムを用いた関心の分離と依存関係の整理

TypeScriptのモジュール構造を表すフォルダツリーとインポート文

クラスを用いたオブジェクト指向設計では、機能の再利用やカプセル化を継承やアクセス修飾子によって実現しようとします。
しかし、このアプローチはしばしば「神クラス(God Class)」と呼ばれる巨大な単一責任原則違反のモジュールを生み出し、結果的にシステム全体の結合度を高めてしまいます。
TypeScriptにおけるモダンな設計では、言語標準のモジュールシステム(ES Modules)を最大限に活用し、クラスのインスタンス化に依存しない関心の分離と依存関係の整理を行います。

名前空間としてのモジュールと単一責任原則

TypeScriptのモジュールシステムは、ファイル単位でスコープを分離し、明示的にエクスポートした要素のみを外部に公開する仕組みを提供します。
これにより、クラスを用いた階層的な名前空間の構築は不要となります。
各ファイルは単一の責務に焦点を当てた純粋関数や型定義の集合体となり、ディレクトリ構造がそのままシステムのドメイン構造を表現します。

例えば、ECサイトの商品管理ドメインを設計する場合、以下のように機能をモジュールとして分割します。

// modules/product/types.ts
export type Product = {
  readonly id: string;
  readonly name: string;
  readonly price: number;
  readonly stock: number;
};
// modules/product/calculator.ts
import { Product } from './types';
export const calculateTax = (product: Product): number => 
  Math.floor(product.price * 0.1);
export const isAvailable = (product: Product): boolean =>
  product.stock > 0;

このようにモジュールを分割することで、在庫判定ロジックと税金計算ロジックが物理的に分離され、それぞれを独立してテストし再利用することが可能になります。
クラスのインスタンスメソッドとしてこれらを定義する場合、インスタンスの生成コストや不要なプロパティへの依存が発生しますが、モジュール単位の関数として定義すればそのようなオーバーヘッドは一切生じません。

依存性の注入とモック化の容易さ

疎結合なシステムを構築する上で重要なのが、依存性の注入です。
クラスベースの設計では、コンストラクタインジェクションを用いてインターフェース経由で依存関係を解決することが一般的です。
しかし、TypeScriptでは構造的型付けの特性を活かし、関数の引数として依存する振る舞いを直接注入するアプローチがよりシンプルで強力です。

以下の表は、クラスベースの依存性注入と関数ベースのアプローチを比較したものです。

観点 クラスベースのDI 関数ベースの引数注入
依存関係の解決 コンテナやコンストラクタが必要 関数呼び出し時に引数として渡す
インスタンス化の必要性 必須(オブジェクト生成のオーバーヘッド) 不要(純粋関数として直接実行)
型の制約 インターフェースの実装が必要 構造的型付けにより暗黙的に解決
テスト時のモック化 モックインスタンスの生成が必要 単純な関数やオブジェクトを渡すのみ

関数ベースの依存性注入は、ミドルウェアパターンやデコレーターパターンを用いて柔軟に実装できます。
例えば、データベースへのアクセスを伴う処理において、リポジトリの振る舞いを関数として定義し、それを引数として受け取ることで、テスト環境ではインメモリのモック関数を容易に注入できます。

循環参照の回避とモジュールグラフの健全性

クラスを用いた設計では、基底クラスと派生クラス、あるいは複数のクラス間の相互参照によって循環依存が発生し、ランタイムエラーやバンドルサイズの肥大化を引き起こすことがあります。
TypeScriptのモジュールシステムは、import文による静的な依存関係グラフを構築するため、ビルドツールや静的解析ツールが循環参照を容易に検出できます。

関数とデータ構造をモジュールとして分割するアプローチを採用することで、データの流れが明確になり、状態の変異が起こる場所を局所化できます。
依存関係は常に「データ型の定義」→「純粋関数による変換」→「副作用の実行」という一方向に流れるため、アーキテクチャ全体が疎結合かつ高凝集な状態を保ちます。

このように、モジュールシステムを適切に活用することで、クラスが持つ暗黙的な状態管理や継承の複雑さに頼ることなく、スケーラブルで保守性の高いTypeScriptアプリケーションを構築することが可能となります。

クラスを使わない設計におけるテスト容易性と品質担保

TypeScriptの純粋関数に対するユニットテストの実行結果画面

ソフトウェア工学の観点において、テスト容易性はアーキテクチャの優劣を測る重要な指標です。
クラスを用いた設計では、インスタンスが保持する内部状態や継承による暗黙的な依存関係により、テストの準備が複雑化しがちです。
一方、関数と型を活用したクラスレスの設計では、純粋関数の性質である参照透過性により、品質担保が論理かつ機械的に行えるようになります。

副作用の局所化とモック化の容易さ

クラスを排除した設計の最大の恩恵は、副作用をシステムの境界(DBアクセスやAPI通信など)に局所化できる点にあります。
ドメインロジックを純粋関数として実装すれば、ユニットテストは単純な入出力の検証に帰着されます。
テストのためにインスタンスを生成し、内部状態を操作するセットアップ作業は不要です。

副作用を伴う処理のテストにおいても、関数の引数として依存性を注入する設計にすることで、モック化が極めて容易になります。

type User = { readonly id: string; readonly email: string };
type UserRepository = {
  findById: (id: string) => Promise<User | null>;
};
// リポジトリを引数として受け取ることで依存を注入
const notifyUser = async (
  id: string,
  repo: UserRepository,
  sendEmail: (email: string, body: string) => Promise<void>
): Promise<void> => {
  const user = await repo.findById(id);
  if (user) {
    await sendEmail(user.email, "通知メッセージ");
  }
};

この設計では、テスト時に本物のデータベースを用意する必要がありません。
インメモリのダミーオブジェクトと、呼び出しを記録するだけのモック関数を渡すだけで、非同期処理を含むロジックの検証が完結します。
クラスのメソッドのように暗黙のthisコンテキストに縛られないため、テストコードは常に平易かつ堅牢なものとなります。

TypeScript開発におけるオブジェクト指向脱却のまとめ

関数型アプローチへの移行を示すTypeScriptのクリーンなアーキテクチャ図

本記事では、TypeScriptにおいてクラスを用いた開発が減少している背景と、オブジェクト指向から脱却するための具体的な代替案について論理的に考察してきました。
コンピューターサイエンスの観点から見れば、オブジェクト指向プログラミングは依然として有効なパラダイムですが、モダンなWeb開発の文脈においては、そのメリットよりもデメリットが顕著になるケースが増えています。

クラスが持つ内部状態のカプセル化は、意図せぬバグの温床となります。
ミュータブルな状態をメソッド経由で変更する設計は、非同期処理や並行処理が主流となる現代のアプリケーションにおいて、状態の追跡を困難にします。
また、継承による階層構造は、コードの再利用性を高める意図で導入されますが、実際には基底クラスの変更が広範なサブクラスに影響を与える「脆弱な基底クラス問題」を引き起こし、保守性を著しく低下させます。

Reactをはじめとするモダンなフロントエンドエコシステムの台頭も、この流れを加速させています。
関数コンポーネントとHooks APIの組み合わせは、クラスが持つthisの束縛問題やライフサイクルメソッドの複雑さを排除し、UIを純粋な関数として表現することを可能にしました。
バックエンド領域においても、tRPCやZodなどの関数と型を中心としたツールチェーンが普及しており、クラスを用いない設計がデファクトスタンダードとなりつつあります。

具体的な代替案として、データと振る舞いを分離する純粋関数の実装、ユニオン型とインターセクション型による柔軟な型表現、そして関数合成と高階関数によるロジックの再利用を紹介しました。
これらの手法を組み合わせることで、クラスの継承に頼らずとも、型安全で拡張性の高いシステムを構築できます。

モジュールシステムを適切に活用し、関心の分離と依存関係の整理を行うことも重要です。
ES Modulesによるファイル単位のスコープ分離は、クラスを用いた階層的な名前空間の構築を不要にし、各モジュールが単一責任原則に従うことを促進します。
依存性の注入も関数の引数として振る舞いを渡すことでシンプルになり、テスト時のモック化も極めて容易になります。

  • 状態の分離: イミュータブルなデータ構造と純粋関数により、副作用を局所化する
  • 型による制約: ユニオン型などを活用し、不正な状態をコンパイル時に弾く
  • 関数による再利用: 継承の代わりに高階関数を用いて、柔軟にロジックを組み合わせる

これらの設計パターンは、単なる流行ではなく、ソフトウェアの複雑性を管理し、長期的な保守性を担保するための論理的な帰結です。
もちろん、クラスが完全に無用であると主張するつもりはありません。
ドメイン駆動設計(DDD)において、複雑なビジネスロジックをカプセル化するエンティティとして活用するなど、依然として有効な場面は存在します。
しかし、デフォルトの設計パターンとしてクラスを選択する時代は終わりつつあります。

TypeScriptの型システムと関数型プログラミングのパラダイムを深く理解し、適材適所で技術を選択できるエンジニアでありたいものです。
本記事が、オブジェクト指向からの脱却を検討する一助となれば幸いです。

コメント

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