クラスと関数
弊社では設定を保持して複数の操作で共有する場合にクラスを、それ以外は関数を選択します。クラスはthis.によるIDE補完、クラス名がそのまま型になる点、設定とメソッドの関係が明確になる点で優れています。関数版ではReturnTypeで別途型を定義する必要があります。
// クラスは型としても使える
function processClient(client: Client) { ... }
// 関数版は別途型定義が必要
type Client = ReturnType<typeof createClient>
APIクライアントやDB接続のように、baseUrlやconnectionをコンストラクタで受け取り各メソッドから参照する構造にはクラスを使います。データ変換や計算のように入力を受け取って結果を返すだけの処理は関数で記述します。
同じ機能をクラスと関数で書く
APIクライアントを例に、クラスと関数で同じ機能を実装します。どちらもbaseUrlとloggerを保持し、getとpostメソッドを提供します。
クラス版
コンストラクタでbaseUrlとloggerを受け取り、private readonlyで保持します。buildUrlは外部から呼ぶ必要がないためprivateにしています。
class Client {
constructor(
private readonly baseUrl: string,
private readonly logger: Logger = new ConsoleLogger(),
) {}
private buildUrl(path: string) {
return `${this.baseUrl}${path}`
}
getとpostはthis経由でコンストラクタの設定とbuildUrlを参照します。設定の参照元がthisに統一されるため、メソッドが増えても構造が変わりません。
get(path: string) {
this.logger.log(`GET ${path}`)
return fetch(this.buildUrl(path))
}
post(path: string, body: unknown) {
this.logger.log(`POST ${path}`)
return fetch(this.buildUrl(path), {
method: "POST",
body: JSON.stringify(body),
})
}
}
関数版
ファクトリ関数の引数baseUrlとloggerはクロージャに捕捉され、返却オブジェクトの各メソッドから直接参照できます。buildUrlはreturnの外側に定義することで外部に公開されません。
function createClient(
baseUrl: string,
logger: Logger = new ConsoleLogger(),
) {
const buildUrl = (path: string) => `${baseUrl}${path}`
return {
get: (path: string) => {
logger.log(`GET ${path}`)
return fetch(buildUrl(path))
},
クラス版と異なり、thisを使わずクロージャ経由で設定を参照します。メソッドの構造はクラス版と同じです。
post: (path: string, body: unknown) => {
logger.log(`POST ${path}`)
return fetch(buildUrl(path), {
method: "POST",
body: JSON.stringify(body),
})
},
}
}
DI の比較
依存性の注入もクラスと関数で同じ構造になります。クラスはコンストラクタ引数で、関数はファクトリ関数の引数で依存を受け取ります。
クラス版
UserRepositoryとMailerをコンストラクタで受け取ります。registerメソッドはthis経由で両方の依存にアクセスします。
class UserService {
constructor(
private readonly repository: UserRepository,
private readonly mailer: Mailer,
) {}
async register(email: string) {
const user = await this.repository.create({ email })
await this.mailer.send(email, "Welcome!")
return user
}
}
本番では実際の実装を、テストではモックを渡します。インターフェースが同じであれば差し替え可能です。
// 本番
const service = new UserService(
new PostgresUserRepository(),
new SendGridMailer(),
)
// テスト
const service = new UserService(
new InMemoryUserRepository(),
new MockMailer(),
)
関数版
ファクトリ関数の引数で依存を受け取り、クロージャ経由で参照します。差し替えの方法はクラス版と同じです。
function createUserService(
repository: UserRepository,
mailer: Mailer,
) {
return {
register: async (email: string) => {
const user = await repository.create({ email })
await mailer.send(email, "Welcome!")
return user
},
}
}
private の比較
クラスではprivate修飾子で、関数ではクロージャ内の変数で外部からのアクセスを制限します。クラスの方が意図が明示的です。
クラス版
countにprivateを付けることで、外部から直接アクセスできなくなります。incrementとgetCountだけがcountを操作できます。
class Counter {
private count = 0
increment() {
this.count++
}
getCount() {
return this.count
}
}
関数版
countはファクトリ関数内のローカル変数です。返却オブジェクトに含まれないため、外部からアクセスできません。privateと同じ効果ですが、言語機能ではなくスコープに依存しています。
function createCounter() {
let count = 0
return {
increment: () => { count++ },
getCount: () => count,
}
}