クラスとモジュール——大きくなったコードを分ける
レッスン6:クラスとモジュール——大きくなったコードを分ける
このレッスンで学ぶこと
- class の基礎を理解し、プロパティとメソッドに型を付けられる
- アクセス修飾子で、外部から触れる範囲を制御できる
import/exportでファイルをモジュールに分割できる- ファイルをまたいで型を共有できる
レッスン5 では、interface と type を使ってオブジェクトの形に名前を付ける方法を学びました。ここまでのレッスンでは、コードを 1 つのファイルにまとめて書いてきましたが、実際のアプリケーションが大きくなると、1 つのファイルにすべてを書き続けるのは現実的ではなくなります。このレッスンでは、「JavaScript 入門」では扱わなかった 2 つの仕組み、class(クラス)とモジュールを学びます。どちらも、大きくなったコードを意味のある単位に分けるための道具です。
「JavaScript 入門」では、オブジェクトと関数を組み合わせてデータと処理をまとめる書き方を学びました。class は、その組み合わせ方に決まった形を与える仕組みです。同じ形のオブジェクトを何度も作る場面や、オブジェクトが持つデータを外部から自由に書き換えさせたくない場面で、class は特に力を発揮します。
classとは——データと処理をひとまとめにする設計図
class は、オブジェクトの「設計図」を作るための構文です。レッスン5 で作った Todo インターフェースは、オブジェクトの形だけを表すものでしたが、class では、形に加えて、そのデータを扱う処理(メソッド)も一緒に定義できます。
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
introduce(): string {
return `私は${this.name}、${this.age}歳です。`;
}
}
const user = new User("森田 彩花", 30);
console.log(user.introduce());
class User { ... } が設計図の定義です。new User("森田 彩花", 30) のように new を付けて呼び出すと、その設計図から実際のオブジェクト(インスタンスと呼びます)が 1 つ作られます。name と age はプロパティ、introduce はメソッドです。「JavaScript 入門」でオブジェクトのプロパティに関数を持たせる書き方をメソッドと呼びましたが、class の中で定義する関数も同じくメソッドと呼びます。
constructor——インスタンスを作るときの初期化処理
constructor は、new でインスタンスを作るときに自動的に呼び出される特別なメソッドです。
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const morita = new User("森田 彩花", 30);
const sato = new User("佐藤 健", 25);
constructor(name: string, age: number) の引数が、new User("森田 彩花", 30) で渡した値とそのまま対応します。this.name = name は、「このインスタンス自身の name プロパティに、受け取った引数を入れる」という意味です。morita と sato は同じ User という設計図から作られていますが、それぞれ独立したプロパティの値を持つ、別々のインスタンスです。
TypeScript では、プロパティの宣言とコンストラクタでの代入をまとめて書ける、より短い書き方も用意されています。
class User {
constructor(public name: string, public age: number) {}
}
const user = new User("森田 彩花", 30);
console.log(user.name); // "森田 彩花"
コンストラクタの引数に public を付けると、「この引数をプロパティとしても持つ」という意味になり、プロパティの宣言と this.name = name のような代入を省略できます。public については、次の項目で詳しく扱います。
アクセス修飾子——外部から触れる範囲を決める
class のプロパティやメソッドには、外部からどこまで触れるようにするかを指定する「アクセス修飾子」を付けられます。主なものは 3 つです。
| 修飾子 | 外部からのアクセス | 用途 |
|---|---|---|
public |
できる(初期状態) | 通常のプロパティ・メソッド |
private |
できない | クラスの内部だけで使う値や処理 |
protected |
できない(継承先からはできる) | 継承したクラスにだけ公開したい値 |
何も指定しない場合、プロパティやメソッドは自動的に public として扱われます。
class BankAccount {
public owner: string;
private balance: number;
constructor(owner: string, balance: number) {
this.owner = owner;
this.balance = balance;
}
deposit(amount: number): void {
this.balance = this.balance + amount;
}
getBalance(): number {
return this.balance;
}
}
const account = new BankAccount("森田 彩花", 10000);
account.deposit(5000);
console.log(account.getBalance()); // 15000
console.log(account.balance); // エラー:private のため外部から参照できない
error TS2341: Property 'balance' is private and only accessible within class 'BankAccount'.
balance(残高)は private にしているため、account.balance のように外部から直接読み書きすることはできません。残高を変更するには、deposit のようにクラスが用意したメソッドを通す必要があります。これにより、「残高は必ず deposit などの決まった手順を通してしか変更できない」というルールを、クラスの設計自体で強制できます。
💡 ポイント
privateは、クラスの利用者に「ここは内部の実装なので、直接触らないでください」と伝える仕組みです。銀行口座の残高のように、勝手な値を代入されると困るデータほど、privateにして専用のメソッド経由でしか変更できないようにする価値があります。
protected は private によく似ていますが、継承先のクラスからはアクセスできるという違いがあります。
class Animal {
protected sound: string = "...";
makeSound(): string {
return this.sound;
}
}
class Dog extends Animal {
constructor() {
super();
this.sound = "ワン"; // protected なので継承先からは変更できる
}
}
const dog = new Dog();
console.log(dog.makeSound()); // "ワン"
console.log(dog.sound); // エラー:クラスの外部からはアクセスできない
Dog クラスの内部からは sound を書き換えられますが、dog.sound のようにクラスの外側から直接アクセスすることはできません。「継承する側には公開したいが、利用する側には隠しておきたい」という中間的な公開範囲を表したいときに protected を使います。
クラスにインターフェースを実装させる——implements
レッスン5 で作ったインターフェースは、クラスの設計図としても使えます。クラスが特定のインターフェースの形を満たすことを約束するには、implements を使います。
interface Greetable {
name: string;
introduce(): string;
}
class User implements Greetable {
constructor(public name: string, public age: number) {}
introduce(): string {
return `私は${this.name}、${this.age}歳です。`;
}
}
class User implements Greetable と書くことで、「User クラスは、Greetable インターフェースが求める name プロパティと introduce メソッドを、必ず持っていなければならない」という約束になります。もし introduce メソッドを書き忘れると、コンパイルの段階でエラーになります。
error TS2420: Class 'User' incorrectly implements interface 'Greetable'.
Property 'introduce' is missing in type 'User' but required in type 'Greetable'.
implements は、レッスン5 で学んだ構造的型付けと同じ考え方の延長線上にあります。「このクラスは、この形を満たしていなければならない」という約束を、クラスの定義そのものに書き込めるのが implements の役割です。複数人でクラスを実装するとき、あらかじめインターフェースで形を決めておけば、実装漏れをコンパイルの段階で見つけられます。
クラスの継承——既存のクラスを土台にする
レッスン5 でインターフェースを extends で拡張したのと同じように、クラスも extends で継承できます。
class User {
constructor(public name: string, public age: number) {}
introduce(): string {
return `私は${this.name}、${this.age}歳です。`;
}
}
class AdminUser extends User {
constructor(name: string, age: number, public permissions: string[]) {
super(name, age);
}
introduce(): string {
return `${super.introduce()}管理者権限:${this.permissions.join("、")}`;
}
}
const admin = new AdminUser("森田 彩花", 30, ["記事の編集", "会員の管理"]);
console.log(admin.introduce());
class AdminUser extends User で、User のプロパティとメソッドを引き継いだ新しいクラスを作っています。super(name, age) は、「継承元である User のコンストラクタを、この引数で呼び出す」という意味です。super.introduce() は、継承元のメソッドをそのまま呼び出しています。AdminUser は独自の introduce を定義していますが、その中で User の introduce の結果を再利用しています。
importとexport——ファイルを分割する
ここまでのレッスンでは、1 つのファイルにすべてのコードを書いてきました。実際のアプリケーションでは、機能ごとにファイルを分け、必要な部分だけをほかのファイルから読み込んで使います。この仕組みを「モジュール」と呼び、export と import という 2 つのキーワードで実現します。
まず、user.ts というファイルに User クラスを定義し、export を付けます。
// user.ts
export class User {
constructor(public name: string, public age: number) {}
introduce(): string {
return `私は${this.name}、${this.age}歳です。`;
}
}
export を付けることで、「このファイルの外からも User を使えるようにする」という意味になります。次に、別のファイル main.ts から、この User を読み込みます。
// main.ts
import { User } from "./user";
const user = new User("森田 彩花", 30);
console.log(user.introduce());
import { User } from "./user" は、「./user.ts というファイルから、User という名前でエクスポートされたものを読み込む」という意味です。./ はファイルのパスが「同じフォルダの中」を指すことを表します。拡張子の .ts は書かずに、ファイル名だけを指定するのが基本の書き方です。
エクスポートの 2 つの形——名前付きとデフォルト
export には、複数のものをエクスポートできる「名前付きエクスポート」と、1 つのファイルにつき 1 つだけ指定できる「デフォルトエクスポート」の 2 つの形があります。
// models.ts
export interface Todo {
id: number;
text: string;
done: boolean;
}
export class TodoList {
private items: Todo[] = [];
add(text: string): void {
this.items.push({ id: this.items.length + 1, text: text, done: false });
}
getAll(): Todo[] {
return this.items;
}
}
// main.ts
import { Todo, TodoList } from "./models";
const list = new TodoList();
list.add("牛乳を買う");
console.log(list.getAll());
models.ts では、Todo インターフェースと TodoList クラスの両方に export を付けて、名前付きエクスポートにしています。読み込む側は、{ Todo, TodoList } のように波括弧の中に必要な名前を並べて、まとめて読み込めます。
// todoList.ts
export default class TodoList {
// ...
}
// main.ts
import TodoList from "./todoList";
export default を付けると、そのファイルの代表として 1 つだけをエクスポートできます。読み込む側は波括弧を付けず、好きな名前を付けて読み込めます。本コースでは、1 つのファイルから複数のものを読み込む場面が多いため、基本的に名前付きエクスポートを使う方針で進めます。
ファイルをまたいだ型の共有
レッスン5 で作ったインターフェースは、ほかのファイルからも import して使えます。型そのものをモジュールとして分割し、複数のファイルで共有する設計は、プロジェクトが大きくなるほど重要になります。
// types.ts
export interface User {
name: string;
age: number;
}
export interface Todo {
id: number;
text: string;
done: boolean;
owner: User;
}
// todoService.ts
import { Todo, User } from "./types";
function createTodo(text: string, owner: User): Todo {
return {
id: Date.now(),
text: text,
done: false,
owner: owner
};
}
types.ts に、アプリケーション全体で使う型だけをまとめておくと、「データの形はこのファイルを見ればわかる」という状態を作れます。複数のファイルが同じ User や Todo の定義を共有しているため、types.ts の中身を 1 か所直すだけで、それを使うすべてのファイルに変更が反映されます。プロジェクトが大きくなるほど、このような型定義の一元管理が効いてきます。
この構造を図にすると、次のようになります。
flowchart TD
T["types.ts<br/>User・Todo を定義してexport"] --> S["todoService.ts<br/>型をimportして関数を書く"]
T --> M["main.ts<br/>型をimportして画面を組み立てる"]
types.ts が土台となる型を提供し、todoService.ts や main.ts がそれぞれの役割に応じて types.ts から必要な型を取り込む、という関係が図から読み取れます。1 つのファイルに全部を詰め込んでいたときと比べ、「どのファイルが何を担当しているか」が一目でわかる構造になります。
🗂 現場のひとこと スクールで「ファイルを分けるべきタイミング」をよく聞かれます。目安にしているのは、1 つのファイルが長くなりすぎて、内容を思い出すのに時間がかかり始めたときです。最初から細かく分けすぎる必要はありません。型・データを扱う処理・画面を組み立てる処理、というように役割の違いが見えてきたら、そこで初めてファイルを分ける、という順番のほうがうまくいきます。
実習:ユーザーとToDoをモジュールに分ける
ここまでの内容を組み合わせて、User クラスと Todo に関する処理を、2 つのファイルに分けてみましょう。
// user.ts
export class User {
constructor(public name: string, private role: string) {}
getRole(): string {
return this.role;
}
}
// todoApp.ts
import { User } from "./user";
interface Todo {
text: string;
done: boolean;
owner: User;
}
const todos: Todo[] = [];
function addTodo(text: string, owner: User): void {
todos.push({ text: text, done: false, owner: owner });
}
const morita = new User("森田 彩花", "講師");
addTodo("レッスン資料を作る", morita);
todos.forEach((todo) => {
console.log(`${todo.owner.name}(${todo.owner.getRole()}):${todo.text}`);
});
user.ts は User クラスだけに責任を持ち、todoApp.ts は ToDo の管理という役割に専念しています。role は private にしているため、todoApp.ts 側からは getRole() を通してしか値を取得できません。ファイルを分けたことで、「ユーザーに関する変更は user.ts だけを見ればよい」という見通しのよさが生まれています。
まとめ
このレッスンでは、以下のことを学びました。
- class はデータ(プロパティ)と処理(メソッド)をひとまとめにした設計図で、
newでインスタンスを作る constructorはインスタンス作成時の初期化処理で、publicを付けるとプロパティの宣言と代入をまとめられるpublic・private・protectedのアクセス修飾子で、外部から触れる範囲を制御できるextendsとsuperでクラスを継承できるexportとimportでファイルをモジュールに分割し、export defaultとの違いを理解した- 型をまとめたファイルを作り、複数のファイルで共有すると、大きなプロジェクトの見通しがよくなる
次のレッスンでは、型を引数のように扱う「ジェネリクス」と、既存の JavaScript コードに少しずつ型を足していく実践的な手順を学びます。ここまでの知識を、実際のコードに応用する段階に進みましょう。
確認クイズ
このレッスンの理解度をチェックしましょう。