メインコンテンツまでスキップ

クラスとインターフェース

TypeScriptにおけるクラスとインターフェースの定義方法とベストプラクティスを学びましょう。

学習のポイント

  • クラスの情報は出来る限り隠蔽する
  • 使用側がクラス内部について知らなくても使えるようにする
  • クラスはなるべく小さく作る
  • クラス継承とコンポジションの違いを知る
  • 凝集度と結合度を意識する

クラスはなるべく小さく作る

大きすぎるクラスは単一責任の原則に違反している可能性があります。適切に分割しましょう。

Bad

❌ Bad: 複数の責任を持つクラス
// ❌ 複数の責任を持つクラス
class Cart {
private items: CartItem[] = [];

addProduct(product: Product): void { /* ... */ }
getProductPrice(productId: string): number { /* ... */ }
getProductCoupon(productId: string): Coupon | null { /* ... */ }
calculate(): number { /* ... */ }
printReceipt(): void { /* ... */ }
}

Good

✅ Good: 責任を分離
// ✅ 責任を分離
class Product {
constructor(
private readonly id: string,
private readonly price: number,
private readonly coupon: Coupon | null = null
) {}

getPrice(): number {
return this.price;
}

getCoupon(): Coupon | null {
return this.coupon;
}
}

class Cart {
private items: Product[] = [];

constructor(initialItems: Product[] = []) {
this.items = initialItems;
}

add(product: Product): void {
this.items.push(product);
}

calculate(): number {
return this.items.reduce((total, item) => total + item.getPrice(), 0);
}
}

class ReceiptPrinter {
print(cart: Cart): void {
// レシート印刷処理
}
}

クラス内部の情報は出来る限り隠蔽する

TypeScriptでは private, protected, readonly 修飾子を使って情報を隠蔽できます。

Bad

❌ Bad: 内部状態が公開されている
// ❌ 内部状態が公開されている
class Television {
modelNumber: string = '型番';

displayModelNumber(): void {
console.log(this.modelNumber);
}
}

const tv = new Television();
tv.modelNumber = 'バグを引き起こす値'; // 外部から変更可能

Good

✅ Good: private と readonly で保護
// ✅ private と readonly で保護
class Television {
private readonly modelNumber: string = '型番';

displayModelNumber(): void {
console.log(this.modelNumber);
}

getModelNumber(): string {
return this.modelNumber;
}
}

const tv = new Television();
// tv.modelNumber = '変更'; // コンパイルエラー
tv.displayModelNumber(); // 正しい使い方

プライベートフィールド(#)

TypeScriptでは JavaScript のプライベートフィールド(#)も使用可能です。

class Circle {
// # で始まるフィールドはランタイムでもプライベート
#radius: number;
readonly #PI = Math.PI;

constructor(radius: number) {
this.#radius = radius;
}

getArea(): number {
return this.#PI * this.#radius ** 2;
}

getPerimeter(): number {
return 2 * this.#PI * this.#radius;
}

// getter/setter でアクセスを制御
get radius(): number {
return this.#radius;
}

set radius(newRadius: number) {
if (newRadius <= 0) {
throw new Error('Radius must be positive');
}
this.#radius = newRadius;
}
}

使用側がクラス内部を知らなくても使えるようにする

クラスの内部構造を知らなくても使えるようにすることで、使いやすいAPIを提供できます。

Bad

❌ Bad: 内部構造を知る必要がある
// ❌ 内部構造を知る必要がある
class Television {
power: boolean = true;
switch: Switch = new Switch();
}

class Switch {
toggle(power: boolean): boolean {
return !power;
}
}

const tv = new Television();
// 使用者がSwitch.toggleの存在を知る必要がある
tv.power = tv.switch.toggle(tv.power);

Good

✅ Good: シンプルなAPIを提供
// ✅ シンプルなAPIを提供
class Television {
private power: boolean = false;
private readonly powerSwitch: Switch = new Switch();

togglePower(): void {
this.power = this.powerSwitch.toggle(this.power);
console.log(`Power: ${this.power ? 'ON' : 'OFF'}`);
}

isPoweredOn(): boolean {
return this.power;
}
}

class Switch {
toggle(state: boolean): boolean {
return !state;
}
}

const tv = new Television();
tv.togglePower(); // 内部実装を知らなくても使える

クラス継承とコンポジション

継承(is-a 関係)

「AはBである」という関係のときに使用します。

// Animal is a Animal の関係
abstract class Animal {
abstract makeSound(): void;

eat(): void {
console.log('食べる');
}

sleep(): void {
console.log('寝る');
}
}

class Dog extends Animal {
makeSound(): void {
console.log('ワン!');
}

sleep(): void {
console.log('小屋で寝る'); // オーバーライド
}
}

const dog = new Dog();
dog.eat(); // 食べる(継承)
dog.sleep(); // 小屋で寝る(オーバーライド)
dog.makeSound(); // ワン!

コンポジション(has-a 関係)

「AはBを持つ」という関係のときに使用します。

// Television has a Switch の関係
class Switch {
toggle(state: boolean): boolean {
return !state;
}
}

class Television {
private power: boolean = false;
private readonly powerSwitch: Switch; // コンポジション

constructor() {
this.powerSwitch = new Switch();
}

togglePower(): void {
this.power = this.powerSwitch.toggle(this.power);
}
}

継承よりコンポジションを優先する

継承は強い結合を生むため、多くの場合コンポジションの方が柔軟です。

❌ Bad: 継承:Attack is a Hero/Enemy という関係は不自然
// ❌ 継承:Attack is a Hero/Enemy という関係は不自然
class Attack {
punch(target: string, damage: number): void {
console.log(`${target}${damage}ダメージを与えた`);
}
}

class Hero extends Attack { }
class Enemy extends Attack { }

// ✅ コンポジション:Hero/Enemy has an Attack
interface AttackAction {
execute(target: string): void;
}

class PunchAttack implements AttackAction {
constructor(private damage: number) {}

execute(target: string): void {
console.log(`${target}${this.damage}ダメージを与えた`);
}
}

class Hero {
constructor(private attack: AttackAction) {}

performAttack(target: string): void {
this.attack.execute(target);
}
}

// 攻撃方法を柔軟に変更可能
const hero = new Hero(new PunchAttack(10));
hero.performAttack('スライム');

インターフェースを活用する

TypeScriptのインターフェースは、クラスの契約を定義するのに最適です。

// インターフェースで契約を定義
interface PaymentMethod {
processPayment(amount: number): Promise<PaymentResult>;
refund(transactionId: string): Promise<RefundResult>;
}

// 複数の実装を提供
class CreditCardPayment implements PaymentMethod {
async processPayment(amount: number): Promise<PaymentResult> {
// クレジットカード決済処理
return { success: true, transactionId: 'cc_123' };
}

async refund(transactionId: string): Promise<RefundResult> {
// 返金処理
return { success: true };
}
}

class PayPalPayment implements PaymentMethod {
async processPayment(amount: number): Promise<PaymentResult> {
// PayPal決済処理
return { success: true, transactionId: 'pp_456' };
}

async refund(transactionId: string): Promise<RefundResult> {
// 返金処理
return { success: true };
}
}

// 使用側はインターフェースに依存
class PaymentProcessor {
constructor(private paymentMethod: PaymentMethod) {}

async checkout(amount: number): Promise<PaymentResult> {
return this.paymentMethod.processPayment(amount);
}
}

凝集度を高める

凝集度とは、モジュール内のデータとロジックの関係性の強さを表す指標です。

Bad(低凝集)

❌ Bad: データとロジックが分散
// ❌ データとロジックが分散
class Product {
price: number = 0;
}

class Shop {
getTaxFreePrice(product: Product): number {
return product.price;
}

getTaxIncludedPrice(product: Product): number {
return product.price * 1.1;
}
}

class OnlineShop {
// 同じロジックを再実装...
getTaxFreePrice(product: Product): number {
return product.price;
}

getTaxIncludedPrice(product: Product): number {
return product.price * 1.1;
}
}

Good(高凝集)

✅ Good: データとロジックを一箇所にまとめる
// ✅ データとロジックを一箇所にまとめる
class Product {
private readonly TAX_RATE = 0.1;

constructor(private readonly price: number) {}

getTaxFreePrice(): number {
return this.price;
}

getTaxIncludedPrice(): number {
return this.price * (1 + this.TAX_RATE);
}
}

// Shop と OnlineShop は Product のメソッドを使うだけ
class Shop {
checkout(products: Product[]): number {
return products.reduce((total, p) => total + p.getTaxIncludedPrice(), 0);
}
}

結合度を低くする(疎結合)

結合度とは、モジュール間の依存度合いを表す指標です。

Bad(密結合)

❌ Bad: 内部プロパティに直接アクセス
// ❌ 内部プロパティに直接アクセス
class Product {
price: number = 0;
TAX: number = 0.1;
discountPercent: number = 0.2;

constructor(price: number) {
this.price = price;
}
}

class Order {
getTaxIncludedTotalPrice(): number {
const product = new Product(3000);
// Product の内部構造に依存
return product.price * (1 + product.TAX) * (1 - product.discountPercent);
}
}

Good(疎結合)

✅ Good: インターフェースを通じてやり取り
// ✅ インターフェースを通じてやり取り
class Product {
private readonly price: number;
private readonly TAX = 0.1;
private readonly discountPercent = 0.2;

constructor(price: number) {
this.price = price;
}

getTaxIncludedPrice(): number {
return this.price * (1 + this.TAX) * (1 - this.discountPercent);
}
}

class Order {
constructor(private readonly products: Product[]) {}

getTaxIncludedTotalPrice(): number {
return this.products.reduce(
(total, product) => total + product.getTaxIncludedPrice(),
0
);
}
}

抽象クラスの活用

TypeScriptの抽象クラスは、共通の実装を持ちながら、特定のメソッドをサブクラスに強制できます。

abstract class Character {
constructor(protected name: string) {}

getName(): string {
return this.name;
}

// サブクラスで実装を強制
abstract startLesson(): void;
}

class Teacher extends Character {
constructor(
name: string,
private subject: string
) {
super(name);
}

startLesson(): void {
console.log(`${this.getName()}: Let's start ${this.subject}`);
}
}

class Student extends Character {
startLesson(): void {
console.log(`Hello! I'm ${this.getName()}.`);
}
}

練習問題

以下のコードを高凝集・疎結合にリファクタリングしてください
// 問題
class Delivery {
area = {
hokkaido: 1000,
tohoku: 600,
kanto: 600,
};
}

class Item {
name: string;
price: number;
quantity: number;

constructor(name: string, price: number, quantity: number) {
this.name = name;
this.price = price;
this.quantity = quantity;
}
}

class Order {
delivery = new Delivery();
items: Item[];
deliveryArea: string;

constructor() {
this.items = [
new Item('apple', 100, 1),
new Item('orange', 200, 2),
];
this.deliveryArea = 'hokkaido';
}

getTotalPrice(): number {
const itemTotal = this.items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
return itemTotal + this.delivery.area[this.deliveryArea as keyof typeof this.delivery.area];
}
}

解答:

✅ Good: リファクタリング後
// ✅ リファクタリング後
type DeliveryArea = 'hokkaido' | 'tohoku' | 'kanto';

class Delivery {
private static readonly FEES: Record<DeliveryArea, number> = {
hokkaido: 1000,
tohoku: 600,
kanto: 600,
};

constructor(private destination: DeliveryArea) {}

getFee(): number {
return Delivery.FEES[this.destination];
}

changeDestination(destination: DeliveryArea): void {
this.destination = destination;
}
}

class Item {
constructor(
private readonly name: string,
private readonly price: number,
private readonly quantity: number
) {}

getName(): string {
return this.name;
}

getSubtotal(): number {
return this.price * this.quantity;
}
}

class Cart {
constructor(private items: Item[] = []) {}

add(item: Item): void {
this.items.push(item);
}

remove(itemName: string): void {
this.items = this.items.filter(item => item.getName() !== itemName);
}

getItemTotal(): number {
return this.items.reduce((total, item) => total + item.getSubtotal(), 0);
}

getItems(): Item[] {
return [...this.items];
}
}

class Order {
private readonly delivery: Delivery;

constructor(
private readonly cart: Cart,
destination: DeliveryArea
) {
this.delivery = new Delivery(destination);
}

getTotalPrice(): number {
return this.cart.getItemTotal() + this.delivery.getFee();
}

changeDestination(destination: DeliveryArea): void {
this.delivery.changeDestination(destination);
}
}

// 使用例
const cart = new Cart([
new Item('apple', 100, 1),
new Item('orange', 200, 2),
]);
const order = new Order(cart, 'hokkaido');
console.log(order.getTotalPrice()); // 1500

改善ポイント:

  1. 各クラスが単一の責任を持つ
  2. プロパティをprivateにして情報を隠蔽
  3. 型で配送エリアを制限
  4. 内部実装に依存しないAPI設計

クラスとインターフェースのまとめ

クラスとインターフェースのベストプラクティス
  • クラスは小さく、単一責任で作る
  • private / readonly で情報を隠蔽
  • 使いやすい API を提供する
  • is-a なら継承、has-a ならコンポジション
  • 継承よりコンポジションを優先
  • インターフェースで契約を定義
  • 高凝集・疎結合を目指す
  • 抽象クラスで共通実装を提供