Featured image of post クライアント開発に独自例外は(ほぼ)要らない

クライアント開発に独自例外は(ほぼ)要らない

エラーは例外ではなく Result で返す。独自例外を定義する前に、本当に throw が必要か考える

要点

クライアント開発において、独自の例外クラスを定義する場面はほとんどない。エラーは 例外ではなく Result で返し、状態として伝播させるのが適切である。

前回の記事では、Exception.message に UI テキストを入れるアンチパターンを扱った。

例外の message に UI テキストを入れてはいけない。エラー情報はレスポンスとして設計する
cover.webp

本記事では、その根本にある「エラーを例外で表現する」という設計そのものを見直す。

なぜ独自例外を作りたくなるのか

典型的な動機は以下の通りである。

  1. エラーの種別を区別したい — ネットワークエラーと認証エラーで処理を分けたい
  2. エラーに情報を付加したい — HTTP ステータスコードやエラーメッセージを保持したい
  3. サーバーがそう返してくるから — サーバーの例外構造をそのままクライアントに持ち込む

いずれも正当な要求だが、例外でなければ実現できない要求ではない

例外の問題点

例外は本来、回復不能な異常事態 を通知する仕組みである。クライアント開発で扱うエラーの多くは、これに該当しない。

エラーの種類 性質 対応
ネットワーク接続失敗 想定内 リトライや代替表示
認証トークン期限切れ 想定内 再認証フローに遷移
バリデーションエラー 想定内 UI にフィードバック
JSON パース失敗 想定内 サーバー仕様変更の調査
NullPointerException 異常 プログラムのバグ。即修正

想定内のエラーを throw で飛ばすと、以下の問題が生じる。

  • 制御フローが不透明になる — どこで catch されるか、コードを追わないとわからない
  • 型安全性がない — 関数のシグネチャにエラーの可能性が表れない 1
  • CancellationException の誤処理 — コルーチンのキャンセルを握りつぶすリスクがある

Result で返す

Kotlin の Result<T> を使えば、成功と失敗を型で表現できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// NG: 例外を throw
suspend fun fetchUser(id: String): User {
    val response = api.getUser(id)
    if (!response.isSuccessful) {
        throw ApiException(response.code(), response.message())
    }
    return response.body() ?: throw NotFoundException("User not found")
}

// OK: Result で返す
suspend fun fetchUser(id: String): Result<User> =
    runCatchingOrCancel {
        val response = api.getUser(id)
        require(response.isSuccessful) { "API error: ${response.code()}" }
        requireNotNull(response.body()) { "User not found: id=$id" }
    }

呼び出し側は onSuccess / onFailure で分岐する。try-catch は不要である。

1
2
3
4
5
6
7
repository.fetchUser(id)
    .onSuccess { user -> _uiState.update { it.copy(user = user) } }
    .onFailure { e ->
        if (e is CancellationException) throw e
        // 実際にはリソース ID や AppErrorType を使う(簡略化のため文字列を直接使用)
        _uiState.update { it.copy(error = "ユーザー情報の取得に失敗しました") }
    }

runCatchingOrCancel

標準の runCatchingCancellationException も捕捉してしまう。コルーチンのキャンセルを握りつぶさないためのラッパーが必要である。

1
2
3
4
5
6
7
8
inline fun <T> runCatchingOrCancel(block: () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }

エラーの種別を区別する

「Result だとエラーの種別を区別できないのでは?」という疑問がある。Result.failure の中身は Throwable なので、型で分岐できる。

1
2
3
4
5
6
7
8
// CancellationException の再 throw は前述の通り省略
.onFailure { e ->
    when (e) {
        is IOException -> showRetryDialog()
        is HttpException -> handleHttpError(e.code())
        else -> showGenericError()
    }
}

ここで使っているのは IOExceptionHttpExceptionライブラリが定義した既存の例外 である。独自例外を定義する必要はない。

より構造化したい場合は、sealed class でエラー型を定義する方法もある。

1
2
3
4
5
sealed class AppErrorType {
    data object Network : AppErrorType()
    data object Unauthorized : AppErrorType()
    data class JsonDecode(val rawJson: String) : AppErrorType()
}

ただし、これは例外ではなく データ である。throw するものではなく、状態として保持する。

まとめ

クライアント開発で扱うエラーの大半は想定内であり、例外ではなく 結果型(Result など)で返す のが適切である。エラーは throw で飛ばすものではなく、状態として伝播させる。

  • 独自例外でなければ実現できない場面は、クライアント開発においては少ない
  • 独自例外を定義する前に、まず結果型で表現できないかを検討する
  • 本当に throw でなければいけないのかを考える

次の記事では、Result と組み合わせたローディング管理を扱う。

ローディング状態を UiState から分離し、IndicatorState で管理する設計を提案する
cover.webp

  1. Kotlin には checked exception がない。関数が何を throw するかは、ドキュメントを読むか実装を追うしかない。 ↩︎

Hugo で構築されています。
テーマ StackJimmy によって設計されています。