TypeScriptのオブジェクト指向設計:カプセル化・インターフェース・委譲
TypeScriptのオブジェクト指向をインターフェースとクラスの例で学びます。カプセル化・抽象化・多態性・継承と委譲、型と実行時の違いを解説します。
オブジェクト指向では、関連するデータとそのデータを扱う処理をまとめ、役割のあるオブジェクト同士を組み合わせてプログラムを作ります。TypeScriptでは、JavaScriptのオブジェクトやクラスに、インターフェースなどの型を組み合わせて設計できます。
クラスを増やせば、自動的に保守性や性能がよくなるわけではありません。変更の影響範囲が分かること、状態の更新方法が明確なこと、必要な処理を差し替えやすいことを目標にします。
この記事の例はインストール手順のNode.js環境で動作し、TypeScript 4.9.4の strict 設定で確認しています。
オブジェクト指向の基本を実装する
クラスとオブジェクトの関係
オブジェクトはプロパティやメソッドを持つ値です。クラスは、そのようなオブジェクトを作成する一つの方法です。オブジェクトリテラルでも作れるため、すべてのオブジェクトにクラスが必要なわけではありません。
例えば、記事の公開状態を管理するなら、状態と状態を変更する処理を一緒に置くと、変更箇所を追いやすくなります。
カプセル化で状態の変え方を決める
src/article-status.ts に保存します。
export {};
class ArticleStatus {
#published = false;
constructor(public readonly title: string) {
if (title.trim().length === 0) {
throw new Error("タイトルは空にできません");
}
}
publish(): void {
this.#published = true;
}
getLabel(): string {
return `${this.title}: ${this.#published ? "公開中" : "下書き"}`;
}
}
const article = new ArticleStatus("TypeScriptの設計");
console.log(article.getLabel()); // TypeScriptの設計: 下書き
article.publish();
console.log(article.getLabel()); // TypeScriptの設計: 公開中
#published はJavaScriptのプライベートフィールドです。外部のコードはこのフィールドへ直接アクセスできず、公開状態の変更には publish を使います。状態の操作方法を限定することが、カプセル化の具体例です。
TypeScriptの private は型チェック時の制約で、# 付きフィールドとは仕組みが異なります。また、readonly も型チェック上の制約です。title が空でないかを調べる処理は、型とは別に実行時に行っています。公式のメンバーの可視性の説明
この例はメモリ上の状態を変更します。データベースへ保存したり、記事を本番サイトへ公開したりする処理は含みません。
共通のインターフェースで振る舞いを差し替える
同じ契約で異なる実装を扱うことを、ポリモーフィズムと呼びます。必ずしもクラスの継承が必要なわけではありません。src/message-service.ts の例では、メッセージの出力先を差し替えます。
export {};
interface MessageWriter {
write(message: string): void;
}
class ConsoleWriter implements MessageWriter {
write(message: string): void {
console.log(message);
}
}
class MemoryWriter implements MessageWriter {
private messages: string[] = [];
write(message: string): void {
this.messages.push(message);
}
getMessages(): readonly string[] {
return [...this.messages];
}
}
class ArticleService {
constructor(private readonly writer: MessageWriter) {}
announce(title: string): void {
this.writer.write(`新着記事: ${title}`);
}
}
const consoleService = new ArticleService(new ConsoleWriter());
consoleService.announce("型の使い方"); // 新着記事: 型の使い方
const memoryWriter = new MemoryWriter();
const testService = new ArticleService(memoryWriter);
testService.announce("クラスの使い方");
console.log(memoryWriter.getMessages()); // ["新着記事: クラスの使い方"]
ArticleService は、渡されたオブジェクトの write を呼びます。具体的な出力先を内部で固定していないため、コンソール表示とメモリへの保存を切り替えられます。別のオブジェクトへ仕事を任せる、この構成を委譲と呼びます。
getMessages では配列のコピーを返しています。呼び出し側へ内部配列そのものを渡さないため、取得した配列の変更が内部の記録を直接変更することを防げます。戻り値の readonly は、さらにTypeScript上での変更操作を制限します。
MessageWriter は、実行時には残らない型の契約です。implements は、クラスの実装がその契約を満たすかを型チェックします。公式の implements の説明
継承と委譲を使い分ける
継承は、既存のクラスを extends で拡張し、親クラスとして扱える子クラスを作る方法です。共通の実装をまとめられる一方、親クラスの変更が子クラスへ影響します。
委譲は、必要な機能を別のオブジェクトへ任せる方法です。上の例では、記事の通知処理と出力先の実装を分けています。複数の出力先を差し替えたい状況では、この構成が役立ちます。
- 同じ種類のオブジェクトとして扱え、共通の実装も必要なら、継承を検討します。
- ある機能だけを交換・再利用したいなら、インターフェースと委譲を検討します。
- 状態を持たない短い変換処理なら、関数だけで十分な場合もあります。
サンプルを実行する
npx tsc src/article-status.ts --strict --target ES2020 --module NodeNext --outDir dist --noEmitOnError
node dist/article-status.js
npx tsc src/message-service.ts --strict --target ES2020 --module NodeNext --outDir dist --noEmitOnError
node dist/message-service.js
二つ目の例は、外部サービスへの送信やネットワーク接続を行いません。依存する処理を差し替える考え方を、ローカルだけで確認できます。
オブジェクトの責務を見直す
変更する理由ごとに分ける
一つのクラスが入力検証、通信、表示をすべて担当すると、変更の影響が広がります。何の理由で修正される処理なのかを考え、独立して差し替えたい部分を別の役割に分けます。
テストできる依存関係にする
外部サービスの呼び出しを引数やコンストラクタから渡せると、テストでは小さな代替実装を使えます。実際の通信をしなくても、成功・失敗時の呼び出し側の振る舞いを確かめられます。