TypeScriptのクラスは、データと振る舞いを型安全にまとめるだけでなく、継承や抽象化といった強力なオブジェクト指向プログラミングを可能にする重要な機能です。
一方で、近年のモダンなフロントエンド開発ではあえて「クラスを使わない」関数型のアプローチも主流となっており、本記事ではクラスの基礎から最新の設計トレンドまでを分かりやすく解説します。
TypeScriptのクラスとは?定義と基本構造
JavaScriptにもクラス構文は存在しますが、TypeScriptにおいてクラスは単なるオブジェクトの設計図にとどまりません。
typescriptのクラスは、データ構造とその振る舞いを定義するだけでなく、コンパイル時には強力な「型」として機能します。
ここでは、クラス定義の基本構造、クラス型としての重要な役割、プロパティやメソッドの書き方を解説します。
- クラスの宣言と「型」としての役割
- プロパティの定義と初期化
- クラスメソッドと定数の書き方
クラスの宣言と「型」としての役割
TypeScriptでクラスを宣言すると、実は「クラスの実体」と「クラスのインスタンスの型」の2つが同時に作成されます。
これにより、作成したクラス名をそのまま変数の型として指定することが可能です。
初心者がよくつまずくのが、「クラスのインスタンス」と「クラスそのもの(コンストラクタ関数)」の型を混同してしまうことです。
関数の引数に user: User と書いた場合、それは「Userクラスを new して作られたオブジェクト」を指します。
「Userクラス自体」を渡したい場合は typeof User と書く必要がある点に注意してください。
// クラスの宣言
class UserProfile {
name: string = "名無し";
}
// 1. クラスを「型」として利用する(インスタンスの型付け)
// user変数はUserProfileクラスのインスタンスしか受け付けない
const user: UserProfile = new UserProfile();
user.name = "Taro";
// 2. 関数の引数にクラスの型を指定する
function printUser(u: UserProfile) {
console.log(`ユーザー名: ${u.name}`);
}
printUser(user); // 正常に動作する
// ❌ エラーになる例(オブジェクトの形が同じでも、明示的な型指定などで怒られる場合がある)
// const fakeUser = { name: "Jiro" };
// 厳密にはTypeScriptの構造的部分型により通ることもありますが、
// instanceofなどで判定するロジックがある場合はバグの原因になります。プロパティの定義と初期化
クラスが持つデータをプロパティと呼びます。
TypeScriptでは、クラス内で使用するプロパティは事前に型を定義しておく必要があります。
また、インスタンス化(new)する際に値を割り当てるには constructorを使用します。
TypeScriptでは strictPropertyInitialization という設定がデフォルトで有効になっており、プロパティを宣言したのに初期値を与えないとコンパイルエラーになります。
エラーを消すために name!: string; のように !(非Nullアサーション)を使って無理やり黙らせる初心者がいますが、実行時エラーの温床になるため避けるべきです。
また、実務ではコンストラクタの引数にアクセス修飾子(public や private)をつける「省略記法」が非常によく使われます。
これを知らないと、無駄に長いコードを書いてしまいます。
class Product {
// ❌ 悪い例:初期化していないとTSに怒られる
// title: string;
// ⭕️ 基本的な書き方(宣言とコンストラクタでの初期化)
id: number;
constructor(id: number) {
this.id = id;
}
}
// ✨ 実務でよく使うベストプラクティス(省略記法)
class PremiumProduct {
// コンストラクタの引数に public や private をつけるだけで、
// プロパティの宣言と代入を自動でやってくれる
constructor(
public id: number,
public title: string,
private price: number // 外部からはアクセスできない変数
) {}
showDetails() {
// クラス内部からは private 変数にアクセス可能
console.log(`${this.title} (ID: ${this.id}) - ¥${this.price}`);
}
}
const item = new PremiumProduct(101, "高級キーボード", 15000);
console.log(item.title); // "高級キーボード"
// console.log(item.price); // 🚨 エラー: price は private プロパティですクラスメソッドと定数の書き方
クラスの振る舞いを定義するのがメソッドです。
また、クラス全体で共有したい値や、絶対に書き換えてほしくない値を定義する場合は、readonly や static 修飾子を使用します。
readonly: インスタンスごとの読み取り専用プロパティ(初期化後は変更不可)。static: クラスそのものに紐づく静的メンバ(インスタンス化しなくても使える)。
クラスメソッドをReactのイベントハンドラーや setTimeout などのコールバックとして渡すと、実行時に this が undefined になりクラッシュする「this抜け」という現象が頻発します。
これを防ぐため、実務では通常のメソッド定義(methodName() {})ではなく、アロー関数を使ったメソッド定義(methodName = () => {})を利用して this を固定する手法がよく使われます。
class ConfigManager {
// typescript クラス 定数 (static と readonly の組み合わせ)
// インスタンス化しなくても ConfigManager.MAX_USERS でアクセスでき、書き換え不可
public static readonly MAX_USERS: number = 100;
// インスタンスごとの読み取り専用プロパティ
public readonly appName: string;
constructor(appName: string) {
this.appName = appName;
// this.appName = "変更"; // 🚨 コンストラクタ以降は再代入不可
}
// 一般的なメソッドの定義(this抜けのリスクあり)
printAppInfo() {
console.log(`アプリ名: ${this.appName}`);
}
// ✨ アロー関数を使ったメソッド定義(thisが固定されるため安全)
safePrintAppInfo = () => {
console.log(`[安全] アプリ名: ${this.appName}`);
}
}
console.log(`最大ユーザー数: ${ConfigManager.MAX_USERS}`);
const config = new ConfigManager("MyAwesomeApp");
// setTimeoutにコールバックとして渡すテスト
// 通常のメソッドは実行時に this が失われ、this.appName が undefined になる
setTimeout(config.printAppInfo, 100);
// アロー関数で定義したメソッドは this が保持されるため安全に動作する
setTimeout(config.safePrintAppInfo, 100);クラスの継承と抽象クラス
TypeScriptでクラスを利用する最大のメリットの一つが、オブジェクト指向プログラミング(OOP)の強力な機能である「継承」と「抽象化」を利用できる点です。
似たような機能を持つクラスを複数作る場合、共通の処理を親クラスにまとめ、子クラスでそれを引き継ぐことで、コードの重複を防ぎ、保守性の高いアプリケーションを構築することができます。
- extendsを使った既存クラスの継承とオーバーライド
- 実装を強制する「抽象クラス」の使い方
extendsを使った既存クラスの継承とオーバーライド
既存のクラスの機能を引き継いで新しいクラスを作ることを「継承」と呼びます。
継承を行うには extends キーワードを使用します。
また、親クラスで定義されているメソッドを、子クラスで独自の実装に書き換えることを「オーバーライド(上書き)」と呼びます。
継承において最も多いエラーが、子クラスに constructor を定義した際、親クラスのコンストラクタを呼び出す super() を書き忘れることです。
これはTypeScriptコンパイラに弾かれます。
また、実務レベルで非常に多い設計ミスが「継承の乱用」です。
「Aを継承してBを作り、Bを継承してCを作り…」と何階層も継承を繰り返すと、あるメソッドがどこで定義されたものか追えなくなり、スパゲッティコード化します。
継承は「is-a関係(例:犬は動物である)」が成り立つ場合のみに留め、それ以外は機能を持った別クラスをプロパティとしてもたせる「コンポジション」という手法を検討するのがベストプラクティスです。
// 親クラス(Base Class)
class Animal {
constructor(public name: string) {}
makeSound(): void {
console.log(`${this.name}が鳴いています...`);
}
}
// 子クラス(Derived Class): Animalを継承
class Dog extends Animal {
public breed: string;
constructor(name: string, breed: string) {
// 🚨 注意: 子クラスでコンストラクタを持つ場合、必ず最初に super() を呼ぶ!
super(name);
this.breed = breed;
}
// 親クラスのメソッドをオーバーライド(独自の実装に上書き)
makeSound(): void {
console.log(`${this.name}(${this.breed}): ワンワン!`);
}
// 子クラス独自のメソッド
fetchItem(item: string): void {
console.log(`${this.name}が${item}を取ってきました。`);
}
}
const myDog = new Dog("ポチ", "柴犬");
myDog.makeSound(); // "ポチ(柴犬): ワンワン!" (オーバーライドされた処理)
myDog.fetchItem("ボール"); // "ポチがボールを取ってきました。"実装を強制する「抽象クラス」の使い方
継承を前提とした設計において、「親クラスでは具体的な処理は書かず、メソッドの名前や引数・戻り値の型だけを定義し、具体的な中身の実装は子クラスに強制させたい」という場面があります。
このような場合に使用するのが、抽象クラスです。
クラス宣言の前に abstract をつけることで抽象クラスとなり、その中に abstract メソッドを定義できるようになります。
初心者が一番よくやるミスは、抽象クラスそのものを new AbstractClass() のようにインスタンス化しようとしてエラーになることです。
抽象クラスはあくまで「設計図の設計図(ひな形)」であるため、直接インスタンス化することはできません。
必ず extends で子クラスを作ってから使用します。
また、実務でよく議論になるのが「interface と abstract class のどちらを使うべきか?」という問題です。
interface は型の定義しか持てませんが、abstract class は「一部のメソッドは共通処理として中身を書き、特定のメソッドだけを子クラスに強制する」といった具体的な処理の共有が可能です。
共通のロジックを持たせたい場合は抽象クラス、単に外側から見た時のルールだけを定義したい場合はインターフェース、と使い分けるのが一般的です。
// 抽象クラス(インスタンス化不可)
abstract class PaymentProcessor {
constructor(protected amount: number) {}
// ⭕️ 共通処理(子クラスでそのまま使える)
printReceipt(): void {
console.log(`領収書: ¥${this.amount} のお支払いを完了しました。`);
}
// ⭕️ 抽象メソッド(中身は書かず、子クラスでの実装を強制する)
abstract processPayment(): void;
}
// ❌ エラー: 抽象クラスは直接インスタンス化できない
// const processor = new PaymentProcessor(1000);
// --- 子クラスの実装 ---
class CreditCardPayment extends PaymentProcessor {
// 🚨 必須: 親クラスの abstract メソッドを実装しないとコンパイルエラーになる
processPayment(): void {
console.log(`クレジットカードで ¥${this.amount} の決済処理を行っています...`);
// クレジットカード特有のAPI通信処理などがここに入る
}
}
class PayPayPayment extends PaymentProcessor {
// 🚨 必須: 同様に独自の実装を行う
processPayment(): void {
console.log(`PayPayで ¥${this.amount} の決済処理を行っています...`);
// PayPay特有のAPI通信処理などがここに入る
}
}
// 実際の利用シーン
const payment1 = new CreditCardPayment(5000);
payment1.processPayment(); // "クレジットカードで ¥5000 の決済処理を行っています..."
payment1.printReceipt(); // "領収書: ¥5000 のお支払いを完了しました。" (親の共通処理)
const payment2 = new PayPayPayment(1200);
payment2.processPayment(); // "PayPayで ¥1200 の決済処理を行っています..."
payment2.printReceipt(); // "領収書: ¥1200 のお支払いを完了しました。"実務で役立つクラスの応用テクニック
TypeScriptのクラスの基本やオブジェクト指向の概念を理解した後は、実際のプロジェクト運用で直面する課題を解決するための応用テクニックを身につけましょう。
ここでは、デバッグやエラーログの記録、あるいは動的なオブジェクトの生成において頻出する「インスタンスから自分自身の情報を取得する」手法について解説します。
- 実行時にインスタンスからクラス名を取得する
実行時にインスタンスからクラス名を取得する
システムでエラーが発生した際、「どのクラスでエラーが起きたのか」をログに記録したい場面は多々あります。
TypeScriptでは、生成されたインスタンスから自分が何のクラスから作られたか、つまりクラス名を取得することが可能です。
最も標準的な方法は、インスタンスの constructor.name プロパティを参照することです。
この constructor.name を使ったクラス名の取得には、本番環境でだけ発生する恐ろしい罠が潜んでいます。
ローカルでの開発中、constructor.name は正しく “User” や “PaymentService” という文字列を返します。
しかし、本番環境向けにWebpackやViteなどでビルドを行うと、ファイルサイズを削減するために変数名やクラス名を短くする「Minify」処理が走ります。
その結果、本番環境では constructor.name が "a" や "t" といった1文字の無意味な文字列に変換されてしまうのです。
もし、if (instance.constructor.name === "User") のような条件分岐を書いていた場合、本番環境では絶対に true にならず、原因不明のバグとしてシステムが停止します。
実務において、ロジックの分岐やデータベースへの保存に constructor.name を絶対に使ってはいけません。
class ProductService {
// ⭕️ 安全な実装:Minifyの影響を受けない明示的な識別子を定義する
public static readonly CLASS_NAME = 'ProductService';
// インスタンスからもアクセスできるようにする場合は public readonly を使う
public readonly typeName = 'ProductService';
fetchProduct() {
// 開発環境のログなど、圧縮されてもシステムが壊れない用途にのみ使用可能
console.log(`[${this.constructor.name}] 商品データを取得しています...`);
}
}
class OrderService {}
const productService = new ProductService();
const orderService = new OrderService();
// --- 1. 一般的なクラス名の取得(typescript クラス 名 取得) ---
console.log(productService.constructor.name); // 開発環境: "ProductService", 本番環境: "a" などの可能性あり
console.log(orderService.constructor.name); // 開発環境: "OrderService", 本番環境: "b" などの可能性あり
// --- 2. ❌ 危険な実装(本番環境で動かなくなる) ---
function processServiceBad(service: any) {
if (service.constructor.name === 'ProductService') {
console.log("商品サービスの処理を実行");
}
}
// 本番ビルド後、service.constructor.name が "a" になると上の分岐に一生入らない
// --- 3. ⭕️ 安全な実装(実務でのベストプラクティス) ---
function processServiceGood(service: any) {
// 静的に定義した識別子、または instanceof を使って判定する
if (service instanceof ProductService) {
console.log("商品サービスの処理を実行(安全)");
}
// ログ出力などで名前が欲しい場合も、明示的に定義したプロパティを使う
if (service.typeName) {
console.log(`実行クラス: ${service.typeName}`);
}
}
productService.fetchProduct();
processServiceGood(productService);クラスを使わない設計手法
これまでの解説でTypeScriptにおけるクラスの強力な機能を紹介してきましたが、実は近年のモダンなWebフロントエンド開発においては、「あえてクラスを使わない」という設計手法が主流になりつつあります。
他のオブジェクト指向言語からTypeScriptにやってきた開発者にとって、クラスが全く登場しないコードベースは奇妙に映るかもしれません。
しかし、これにはJavaScript/TypeScriptのエコシステム特有の深い理由があります。
ここでは、なぜ現代の開発現場でクラスが避けられる傾向にあるのか、そしてクラスの代わりにどのようなアプローチが取られているのかを解説します。
- なぜクラスを避けるのか?
- Typeと関数を組み合わせた関数型アプローチ
なぜクラスを避けるのか?
TypeScriptでクラスを使わない設計が広まった最大の要因は、フロントエンドの覇権を握るフレームワーク「React」のパラダイムシフトです。
かつてのReactでは、画面の部品をクラスで定義していました。
しかし、クラスには「thisの挙動が直感的でなくバグを生みやすい」「ロジックの再利用が難しい」といった課題がありました。
そこでReact Hooksが登場し、現在ではすべてのコンポーネントを「関数」として定義するスタイルが標準化されました。
また、クラスを避ける技術的な理由として「Tree-shaking」の効率が挙げられます。
クラスに紐づくメソッドは、一部しか使っていなくてもクラス全体がバンドルされてしまいがちですが、独立した関数であれば、使われていない関数はビルド時に綺麗に削除され、パフォーマンス向上に繋がります。
// ❌ 悪い例:他言語の癖で、無意味なユーティリティクラスを作ってしまう
class DateFormatter {
// すべてがstaticで状態を持たないなら、クラスにする意味がない
static formatToJp(date: Date): string {
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
}
}
// 呼び出し:DateFormatter.formatToJp(new Date());
// ⭕️ 良い例(モダンなTS):個別の関数として定義し、直接エクスポートする
// utils/date.ts のような別ファイルに定義するイメージ
export function formatToJp(date: Date): string {
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
}
// 呼び出し:formatToJp(new Date());
// ※使う関数だけを import するため、Tree-shakingが効きやすい!Typeと関数を組み合わせた関数型アプローチ
クラスを使わない場合、データとロジックはどのように管理するのでしょうか?その答えが、「データ構造とそのデータを操作する純粋な関数」を分離する関数型プログラミングのアプローチです。
クラス(オブジェクト指向)の世界では、「データ」と「それを操作するメソッド」を一つの箱に閉じ込めます。
一方、関数型のアプローチでは、データは単なるプレーンなオブジェクトとして扱い、その状態を変更したい場合は「古いデータを受け取り、新しいデータを返す関数」を使用します。
// --- 従来のクラスベースのアプローチ(状態と振る舞いが同居) ---
class UserAccount {
constructor(public id: number, public name: string, public isPremium: boolean) {}
upgrade() {
this.isPremium = true; // 🚨 自身の状態を直接書き換える(ミュータブル)
}
}
const user1 = new UserAccount(1, "Taro", false);
user1.upgrade();
// --- ✨ モダンな関数型アプローチ(Typeと関数の分離) ---
// 1. データ構造は単なるType(またはInterface)で定義する
type User = {
readonly id: number;
readonly name: string;
readonly isPremium: boolean;
};
// 2. ユーザーを生成するファクトリー関数
function createUser(id: number, name: string): User {
return { id, name, isPremium: false };
}
// 3. データを操作する関数(🚨 元のデータを書き換えず、新しいデータを返す)
function upgradeUser(user: User): User {
// ❌ 悪い例:user.isPremium = true; return user;
// ⭕️ 良い例:スプレッド構文で元のデータをコピーしつつ、必要な箇所だけ上書きした「新オブジェクト」を返す
return {
...user,
isPremium: true
};
}
// 実際の利用例
const baseUser = createUser(2, "Jiro");
// upgradeUser関数にデータを渡し、アップグレードされた「新しいユーザーデータ」を受け取る
const upgradedUser = upgradeUser(baseUser);
console.log(baseUser.isPremium); // false (元のデータは保たれている:安全!)
console.log(upgradedUser.isPremium); // true (新しいデータ)
