なぜTypeScriptでメソッド記法を避けるべきか?実務に近い事例の紹介

参考文献

はじめに

TypeScriptでは、オブジェクトの型エイリアスには主に2通りの方法で関数を含めることができます。

  • 「メソッド記法」と呼ばれる方法
  • プロパティとして関数を定義する方法
type User = Readonly<{ id: string }>
type UserRepository = {
    // メソッド記法
    save1(user: User): Promise<void>
    // プロパティとして関数を定義
    save2: (user: User) => Promise<void>
}

しかし、「プロを目指す人のためのTypeScript入門」を始め、さまざまな書籍やブログ記事で「メソッド記法」は避けるべきと解説されています。これは、メソッド記法を利用した場合は双変となってしまい、引数がサブタイプでも型検査を通過してしまうためです。この問題の背景については下記の記事が詳しいです。

zenn.dev

しかし、「具体的にメソッド記法が問題となるケースが思い当たらない」という方も多いと思います。この記事では、実際のバックエンド開発で発生しうる事例を参考に考えてみましょう。

事例

タスク管理サービス

Jiraのようなタスク管理サービスを例に考えてみましょう。タスクのステータスは ToDo Doing Done のいずれかを取り、ステータスごとに個別のプロパティを持つとします。

type User = { id: number }
type Task =
  | { id: number, status: 'ToDo' }
  | { id: number, assignee: User, status: 'Doing' }
  | { id: number, assignee: User, doneAt: Date, status: 'Done' }

ここで、Doing 状態のタスクを ToDo 状態へ戻すユースケース UnassignTaskUseCase を実装します。

type Ok = { ok: true }
type Err = { ok: false, reason: 'NOT_FOUND' | 'BAD_STATUS' }
type UnassignTaskUseCase = {
  run: (taskId: number) => Promise<Ok | Err>
}

メソッド記法のリポジトリ

このユースケースを実装するためには、タスクを取得する処理と、タスクを永続化する処理が必要です。これらを TaskRepository が担うとします。ここで注意すべき点は、リポジトリsave がメソッド記法で定義されていることです。

type TaskRepository {
  find: (taskId: number) => Promise<Task | undefined>
  save(task: Task): Promise<void>
  // ^^^ メソッド記法
}

const newUseCase = (taskRepository: TaskRepository): UnassignTaskUseCase => ({
  run: async (taskId: number) => {
    const task = await taskRepository.find(taskId)
    if (!task) {
      return { ok: false, reason: 'NOT_FOUND' }
    }
    if (task.status !== 'Doing') {
      return { ok: false, reason: 'BAD_STATUS' }
    }
    await taskRepository.save({ id: task.id, status: 'ToDo' })
    return { ok: true }
  }
})

サブタイプを引数に取るリポジトリ実装

ユースケースへ渡す TaskRepository の実体を実装します。ただし、store が特定のサブタイプのみを引数に取るとします。例えば、ある実装者が「statusDoing の場合は assignee の存在チェックをしたい」と考えたとしましょう。

// Doingの場合は永続化する時に `assignee` がUserテーブルに存在するか検証する
const saveDoing = (task: Task & { status: 'Doing' }) => {
  console.log(task.assignee.id)
  // task.assignee.id の存在チェック
  return Promise.resolve(undefined)
}

const tasks = [{ id: 1, status: 'ToDo' }] as const
const taskRepository = {
  find: (taskId: number) => Promise.resolve(tasks.find(x => x.id === taskId)),
  save: saveDoing,
} as const

ここで、実装したリポジトリを基にユースケースを作成してみましょう。

「プロパティとして関数を定義する方法」であれば、 newUseCase にタスクリポジトリを渡した時点でコンパイルが失敗します。

しかし、「メソッド記法」でユースケースの依存するリポジトリを定義した結果、以下のように型検査を通過してしまいました。そして、実行に失敗します。

const useCase = newUseCase(taskRepository)
//                         ^^^^^^^^^^^^^^
//                         型検査を通ってしまう
useCase.run(1)
// Cannot read properties of undefined (reading 'name')

Playground Link から実際に試してみてください。

プロパティとして関数を定義した場合

ここで、プロパティとして関数を定義した場合を見てみましょう。

type TaskRepository {
  find: (taskId: number) => Promise<Task | undefined>
  save: (task: Task) => Promise<void>
  // ^^^ 関数プロパティ
}

この場合、きちんとコンパイルが失敗します。

const useCase = newUseCase(taskRepository)
//                         ^^^^^^^^^^^^^
// Argument of type
// '{
//    readonly find: (taskId: number) => Promise<{ readonly id: 1; readonly status: "ToDo"; } | undefined>;
//    readonly save: (task: Task & { status: "Doing"; }) => Promise<undefined>;
// }' is not assignable to parameter of type 'TaskRepository'.
//  Types of property 'save' are incompatible.

まとめ

メソッド記法を避けることで、依存性注入時に不正な実装を注入することを防止できます。 やや冗長な例となってしまいましたが、参考になれば幸いです。