🆗

Result型とESLintでエラーハンドリング漏れを検出する

2023/03/31に公開

こんにちは、よしこです。

この記事では、わたしの所属する株式会社ナレッジワークで最近コードベースに取り入れた「エラーハンドリング漏れ防止の仕組み」について紹介します。

背景

「通信を伴うアクションに失敗しても画面にエラーフィードバックが表示されない」という実装漏れをしてしまったことがあり、今後こういうことが起きないように仕組みで防止したいと思いました。

「忘れてしまった」という問題なので、テストで担保するのも難しいように思いました。実装するのを忘れてしまっているということは、テストを書くこともセットで忘れてしまっているはずだからです。

「気をつける」「チェックリストを作る」のような人間が注意する方向ではなく、「嫌でも気付く」「忘れていたらCIが通らない」のように、必要なハンドリングを強制する形にできないか?と思いました。

課題

実行時に通信エラーが起きる可能性があり、ユーザーフィードバックを伴うエラーハンドリングを必ずおこないたいレイヤーは、弊社の設計でいうとusecaseレイヤーになります。

既存のusecaseのメソッドを抜粋すると、以下のような形です。

  // usecase objectに生えているメソッドのひとつ
  async createUser(seed: UserCreateSeed) {
    try {
      const { user } = await repository.createItem(seed)
      // ユーザーが増えたので既存の一覧のキャッシュデータをクリア
      mutator.mutateList()
      return { user }
    } catch (error) {
      reportException(error)
      // 画面へ表示する用の汎用エラーをthrow
      const errorMessage = '予期せぬエラーが発生しました。再度お試しください。'
      throw new Error(errorMessage)
    }
  }

利用側のComponentは以下のような形になります。

  const { createUser } = useUserUsecase()
  const seed = useFormValue() // 疑似コード、formからの値

  const handleCreate = useCallback(async () => {
    const { user } = await createUser(seed)
    // 作成成功したらユーザー詳細ページへ遷移
    router.push(`/users/${user.id}`)
  }, [seed, createUser, router])

  return (
    <>
      <Button onClick={handleCreate}>作成</Button>
    </>
  )

しかし、上記コードがまさにエラーハンドリングを忘れてしまっているコードです。
正しくは以下のようなハンドリングが必要になります。(通信中表示などは省略)

  const { createUser } = useUserUsecase()
  const seed = useFormValue() // 疑似コード、formからの値
+ const [errorMessage, setErrorMessage] = useState('')

  const handleCreate = useCallback(async () => {
+   try {
+     setErrorMessage('')
      const { user } = await createUser(seed)
      // 作成成功したらユーザー詳細ページへ遷移
      router.push(`/users/${user.id}`)
+   } catch (error) {
+     if (error instanceof Error) {
+       setErrorMessage(error.message)
+     }
+   }
  }, [seed, createUser, router])

  return (
    <>
      <Button onClick={handleCreate}>作成</Button>
+     {errorMessage}
    </>
)

すべてのusecaseレイヤーのメソッド呼び出しに対して、こういったエラーハンドリングを強制する方法はないでしょうか?

awaitableな関数はすべてtry-catchで囲むべき、という考え方もあるかもしれないですが、usecase以外の文脈ではthrowされた例外をそのまま上位の呼び出し先へ投げたい場合もあるので、try-catchしないケースもあります。
また、usecaseの呼び出しであっても、ものによってはエラーが起きた場合にusecaseの中でToast表示などによるエラーフィードバックを終わらせていて、Component側ではエラーフィードバックが不要なケースもあります。

現状、usecaseの呼び出し時にtry-catchと画面へのエラーフィードバックが必要かどうかは、usecaseのメソッド実装を見ないとわからなくなってしまっています。
これは、JavaScript/TypeScriptの例外によるエラー伝搬における「その関数が例外を投げるかどうかは関数を使う側からはわからない」という特徴が大きく関係しています。

「このusecaseは呼び出し側でハンドリングが必要なエラーを発生させうるのか?」
これをusecaseの呼び出し側で知れるようにしたい、と考えました。

解決のアプローチ

usecaseの返り値にResult型を導入することで、型レベルでその判別ができないかを考えます。

TypeScriptにおけるResult型については以下の記事が参考になりました。
https://blog.ojisan.io/my-new-error/

上記を踏まえ、Result型の持つ結果のバリエーションとして以下の3パターンを用意しました。

  • Ok<T> - 成功
  • Err<Error> - 失敗かつエラーフィードバックが未了(呼び出し側で処理する必要がある)
  • Err<null> - 失敗かつエラーフィードバックが済(呼び出し側で処理する必要がない)

Err<null> を設けたのは、エラーハンドリングをしなくていい場合でも、処理自体は失敗に終わっているという情報を持たせたかったためです。エラーハンドリングが不要だからといって失敗の場合でも Ok を返すよりも、 Err<null> のほうが明示的になるかなと思いました。

また、大前提として、Resultのインターフェイスを使うのはusecaseの返り値のみに限定しようと思いました。少しプロパティが深くなり扱いが冗長になる型ではあるため、漏れ出してしまってComponentのpropsやRepositoryなどにも染み出してしまうと嬉しくないからです。

Result型の実装はシンプルで、以下のような形です。

result.ts
export type UsecaseResult<T, E extends Error | null> = Ok<T> | Err<E>

type Ok<T> = {
  readonly ok: true
  readonly val: T
  readonly err: null
}

type Err<E extends Error | null> = {
  readonly ok: false
  readonly val: null
  readonly err: E
}

export const usecaseResultOk = <T>(val: T): Ok<T> => ({
  ok: true,
  val,
  err: null,
})

export const usecaseResultError = <E extends Error | null>(err: E): Err<E> => ({
  ok: false,
  val: null,
  err,
})

インターフェイスは先の記事で紹介されていたライブラリ群の定義を参考にしつつも、usecaseの返り値だけでの利用であれば値のチェインなどは不要と考え、helperまわりは存在しない最低限の実装のみとしました。
usecaseの返り値でだけ利用したいことを明示するために、 Result ではなく UsecaseResult という冗長な命名にしています。

これを適用すると、usecaseの実装は以下のようになります。

  // usecase objectに生えているメソッドのひとつ
  async createUser(seed: UserCreateSeed) {
    try {
      const { user } = await repository.createItem(seed)
      // ユーザーが増えたので既存の一覧のキャッシュデータをクリア
      mutator.mutateList()
-     return { user }
+     return usecaseResultOk({ user })
    } catch (error) {
      reportException(error)
      // 画面へ表示する用の汎用エラーをthrow
      const errorMessage = '予期せぬエラーが発生しました。再度お試しください。'
-     throw new Error(errorMessage)
+     return usecaseResultError(new Error(errorMessage))
    }
  }

利用側のComponentは以下のような形になります。

  const { createUser } = useUserUsecase()
  const seed = useFormValue() // 疑似コード、formからの値
+ const [errorMessage, setErrorMessage] = useState('')

  const handleCreate = useCallback(async () => {
+     setErrorMessage('')
      const result = await createUser(seed)
+     if (result.ok) {
        // 作成成功したらユーザー詳細ページへ遷移
        router.push(`/users/${result.val.user.id}`)
+     } else { 
+       setErrorMessage(result.err.message)
+     }
  }, [seed, createUser, router])

  return (
    <>
      <Button onClick={handleCreate}>作成</Button>
+     {errorMessage}
    </>
)

await createUser の返り値の型が Ok<{ user: User }> | Err<Error> になっており、 Err<Error> が含まれるので、呼び出し側でエラーフィードバックが必要であることがわかります。
また、それにより result.ok がfalseであれば result.errError と推論されるので、try-catch方式のcatch節では必要になっていたinstanceofでのError型チェックが不要になっているのも嬉しいポイントです。

逆にComponentでのエラーフィードバックが不要なusecaseでは、エラー時にusecase内でエラーフィードバックをしたあと return usecaseResultError(null) を返すことで呼び出し側でのエラーフィードバックが不要であることを型レベルで伝えることができます。

当初の「このusecaseは呼び出し側でハンドリングが必要なエラーを発生させうるのかどうかをusecaseの呼び出し側で知れるようにしたい」というニーズは、返り値の型にその情報を持たせることで達成できました。

自動で気付ける仕組み

次は、これらに違反した際に気付ける仕組みを作っていきましょう。

Usecase側

すべてのusecaseメソッドの返り値がResult型になるよう強制するにはどうしたらよいでしょうか?
そもそものusecaseの設計は別記事で紹介しています

Usecase Objectの型を以下のように定義します。

type UsecaseMethod = (...args: any[]) => 
  UsecaseResult<unknown, Error | null> |
  Promise<UsecaseResult<unknown, Error | null>>
export type Usecase = Record<string, UsecaseMethod>

既存のusecase objectにsatisfiesで適用します。

export const createUserUsecase = () => ({
  async createUser(seed: UserCreateSeed) {
    // ...
  },
  // ...ほか更新や削除などのメソッド
} satisfies Usecase)

これでusecase methodの返り値にResult型を強制しつつ、返り値の推論も活かせるようになりました。

Component側

エラーフィードバックの必要性が型に表れたことで、関数の中身を見なくても返り値の型を見れば必要性がわかるようになり一歩前進したものの、現時点では「返り値の型を確認して Err<Error> が含まれる場合は忘れずにエラーフィードバックをする」という運用になります。これではまだ「気をつける」から脱せていません。

加えて、前述のcreateUserのように正常系で返り値を利用するケースでは result.ok を確認してから正常系の値を取得する必要があるので異常系についても自然な流れでelse節などに書きやすいのですが、正常系で返り値が不要な場合は以下のように書いてしまうケースがあります。

const handleDelete = async (id: string) => {
  await deleteUser(id)
}

await deleteUser の返り値は Ok<null> | Err<Error> なのでエラーフィードバックが必要なのですが、正常系では返り値が不要で変数に格納する必要がないためawaitしただけになっていて、そのまま異常系のことも忘れてしまうケースです。
これがありうると、try-catcy方式の場合は内部で発生した例外が上位まで飛び最終的にSentryなどで補足されるのでまだ後から気付くことができるのですが、Result方式の場合は返り値の Err<Error> が変数に格納されず闇に葬られるだけとなり、try-catch方式よりも漏れに気付きにくくなってしまっています。

このように参照されていない Err<Error> を検出してエラーを出せるようにしたいです。
幸いなことに検出したい対象が型に表れているので、typescript-eslintで実現できそうです。

参照されていない Err<Error> をlintエラーにする

これを実現するため、以下のeslint pluginを作成しました。

https://github.com/knowledge-work/eslint-plugin-return-type

return-type/enforce-access というルールで「特定の型の返り値が使われずに棄てられていたらlintエラーにする」という挙動を実現できます。
このeslintルールはResult型に限ったものではなく、「特定の型」はeslintrcで正規表現を使って指定できるので、何にでも使えます。

今回の例でいうと、以下の指定をしています。

    "return-type/enforce-access": [
      "error",
      { "typeNames": ["Err<\\w*Error>", "Promise<.*?Err<\\w*Error>.*?>"] }
    ],

Errorを継承したCustomErrorなどもまとめて対象にできるよう Err<*Error> のようなイメージで後方一致にしています。

また、2つ目の指定でPromiseの中の Err<*Error> も対象にできるようにしています。
Promiseのケアは @typescript-eslint/no-floating-promises が有効であればそちらでケアされるのでここのoptionには入れなくてもよかったのですが、弊社のeslint configでは残念ながら有効になっていませんでした。。
(余談ですが最近 "plugin:@typescript-eslint/recommended-requiring-type-checking" というrecommendedなconfigが新しくできていて、no-floating-promisesもそこに含まれているようなので、これからプロジェクトを立ち上げる方にはこちらのconfigをおすすめします)

このeslintを入れることで、前述の問題コードを以下のようにlintで検出できるようになりました。

以下のように返り値を変数に格納するとenforce-accessルールによるエラーは出なくなります。

が、このままだと使われていないres変数に対して no-unused-vars ルールによるエラーが出てしまうので、何かしらresにアクセスする必要があります。
そうなれば res.ok を見ざるを得ないので、エラーハンドリングのことを思い出せそうですね。
これで、エラーフィードバックが必要な文脈では限りなくハンドリングに近いところまでの処理を強制することができました。

ちなみに返り値がエラーフィードバックの必要ない Ok<T> だけのときや Ok<T> | Err<null> の場合はenforce-accessのoptionに指定したtypeNamesに当てはまらないので、今まで通りawaitしっぱなしでもlintエラーになりません。

まとめ

以下の設計により、エラーフィードバック漏れに自動で気付ける仕組みを作れました。

  • エラーフィードバックの必要性を返り値の型で明示する
  • エラーフィードバックが必要である返り値がアクセスされず棄てられていたらlintエラーにする

自動で気付ける形を目指したのは、ナレッジワークが事業領域としている「イネーブルメント」の思想の影響も大きいです。
「育成」が「人のスキルを上げて、できることを増やす」だとすると、「イネーブルメント」は「人のスキルが変わらない状態でも、環境や情報を整備することで、できることを増やす」という思想の違いがあります。
今回の改善で、実装漏れをした過去の自分のようにエラーハンドリングが漏れていることに気付ける注意力がない人でも、型やlintのエラーを修正していけば手元に Ok<T> | Err<Error> 型の変数がある、という状態を実現して自然とそれぞれのハンドリングをするという形にもっていけたかなと思います。イネーブルメントという思想をひとつ実現できたように感じました。

(こういった考え方や事業領域に関心を持っていただけた方はぜひ 会社紹介お話しの機会をなにとぞ🙏 現在フロントエンドチーム、拡大が急務です…!)

余談

今回作った eslint-plugin-return-type は、GPT-4のChatGPTとペアプロする形で作りました。
typescript-eslintでの型情報を活用したlintルールを作るのは初めてだったのですが、大枠をGPT-4が教えてくれ、 ESLintUtils TSESLint.RuleTester などのhelperの存在やルール本体の中で必要なASTの種別判定の仕方を一足飛びに知ることができました。おかげで半日ほどで作成することができました。
また、リポジトリの構成は以下の記事とそのリポジトリの構成を大変参考にさせていただきました。とても見通しのよい構成で、ルールもテストもTSで書くことができてよかったです。
https://zenn.dev/mkpoli/articles/fce02f0f4d45fa

そしてtypescript-eslintの便利なhelperのおかげでサクッとこのeslintルールを作れて嬉しくなっていたときにちょうど TypeScript-ESLintへの寄付を募る記事 を読んだので、 嬉しさのままに寄付しておきました。皆様もぜひどうぞ。

https://twitter.com/yoshiko_pg/status/1638532584652288000


以下の記事では他にも様々なフロントエンド設計の工夫を紹介しています。よければあわせて読んでみてください!

https://zenn.dev/yoshiko/articles/32371c83e68cbe

Discussion