【Flutter】複数の Result 型 × 関数型スタイルでエラーハンドリングのネスト地獄から抜け出す
はじめに
前回の記事では、Dart における try-catch-throw では複雑になりがちなエラーハンドリングを、
Result 型 × sealed class × AppException
の3つを組み合わせて設計する方法をご紹介しました。
ただ、実際のアプリでは
- 複数のリポジトリをまたいで処理をつなげたい
- それぞれが別々の
Result型(別々の例外グループ)を返してくる
といったケースが多く、
「Result をうまく組み合わせたいのに、コードがネストだらけで読みにくい」
という問題が出てきます。
この記事では、そのような「複数の Result 型をつなげたい場面」を題材に、
- 例外グループをどう束ねるか
-
mapError/flatMapなどを使った 関数型スタイル で
ネストを抑えつつ、読みやすさを保つにはどうすればいいか
といった点を、具体的なコード例とともに解説していきます。
記事の対象者
- Flutter / Dart でアプリ開発をしていて、エラーハンドリングのコードがだんだん複雑になってきたと感じている人
-
try-catchやResultのネストが増えてきて、**「とりあえず動くけど読みづらい…」**というモヤモヤを抱えている人 -
freezedやsealed classを使って、独自の例外クラスや Result 型を定義したことがある / これから使ってみたい人 - 関数型プログラミングそのものには詳しくないけれど、
map/flatMapなどを使った「関数型っぽい」書き方で可読性を上げたいと考えている人 - Riverpod などを使ったレイヤードアーキテクチャで、
Repository や UseCase 層の「Result のつなぎ方・設計の仕方」を具体例付きで知りたい人
記事を執筆時点での筆者の環境
[✓] Flutter (Channel stable, 3.35.4, on macOS 26.0.1 25A362 darwin-arm64, locale ja-JP)
[!] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.104.3)
[✓] Connected device (5 available)
[✓] Network resources
主なパッケージ
- freezed
- riverpod
dependencies:
flutter_hooks: ^0.21.3+1
hooks_riverpod: ^3.0.3
riverpod_annotation: ^3.0.3
freezed_annotation: ^3.1.0
go_router: ^17.0.0
dev_dependencies:
build_runner: ^2.7.1
riverpod_generator: ^3.0.3
riverpod_lint: ^3.0.3
freezed: ^3.2.3
サンプルプロジェクト
解説しないこと
今回はDI(依存性注入)にRiverpodを使っていますので、その前提でのコードになっています。
この点については解説していませんので、ご了承ください。
過去に書いた記事で解説していますので、よろしければそちらをご覧ください。
Result型を使っていく上での問題点
前回の記事でお話しした Result 型と sealed class を使えば、
処理が失敗した場合のハンドリングを、呼び出し側で try-catch を書かずに
漏れなく扱える ことを紹介しました。
ただし、ここで問題になってくるのが、冒頭でも触れた
「複数の Result 型を組み合わせて使いたいとき」 です。
例えば、次のようなケースが考えられます。
- リポジトリ内のいくつかの関数を組み合わせて、ひとつの関数を作る場合
- 複数のリポジトリを呼び出して、サービス層やユースケース層で新しい関数を作る場合
こういった場合は、
「関数1の戻り値を関数2の引数に渡して処理する」
という流れが必ず発生します。
しかし、ここで単純に Result をつなげようとすると、
- 成功の型はどちらも
Userなのに - 失敗側の型(例外グループ)がそれぞれ別 (
FetchUserException/SaveUserException)
という事情があり、処理をそのままつなげるだけでは
いくつかのつまずきポイント が出てきます。
この記事では、その具体例として UserRepository の 2つの関数を題材にし、
最終的に getUser という関数を組み立てていく過程を見ていきます。
2つの関数を組み合わせる
ここからは、実際に UserRepository の中で「複数の Result をどうつなげていくか」を見ていきます。
まず材料になるのは、次の2つの関数です。
- サーバーからユーザー情報を取得する
_fetchUserFromServer - 取得したユーザー情報をローカルに保存する
saveUser
最終的なゴールは、この2つを組み合わせて
サーバーからユーザー情報を取得
→ ローカルに保存
→ 保存したユーザー情報を呼び出し元に返す
という一連の流れを1つにまとめた getUser 関数を作ることです。
ただし、両方の関数は成功時の型こそどちらも User で共通しているものの、
失敗時の例外グループは
-
_fetchUserFromServer……FetchUserException -
saveUser……SaveUserException
と別々になっています。
そのため、単純に結果をつなげていくだけでは、
- 例外の型をどう1つにまとめるか
-
switchやifのネストをどう抑えるか
といった点で、いくつか工夫が必要になります。
そこでこの章では、
- まず
saveUser/_fetchUserFromServerの定義を確認し、 - それらが投げる例外を束ねる
GetUserExceptionを用意し、 - それを使って
getUserを段階的に実装・リファクタリングしていく
という流れで解説していきます。
関数① saveUser関連の定義
saveUser から発生する例外グループとその詳細の定義は以下です。
sealed class SaveUserException extends AppException {
SaveUserException(super.type);
}
class SaveUserStorageException extends SaveUserException {
SaveUserStorageException(super.type);
}
class SaveUserPermissionException extends SaveUserException {
SaveUserPermissionException(super.type);
}
class SaveUserUnexpectedException extends SaveUserException {
SaveUserUnexpectedException(super.type);
}
saveUser の戻り値型の型エイリアスです。
typedef SaveUserResult = Result<User, SaveUserException>;
saveUser の実装です。
/// ユーザー情報をローカルに保存する
Future<SaveUserResult> saveUser(User user) async {
try {
// 実際にはSharedPreferencesやIsarなどで保存する想定
await Future<void>.delayed(const Duration(milliseconds: 100));
// 正常に保存完了
return Result.success(user);
} on StorageException {
return Result.failure(
SaveUserStorageException(AppExceptionType.saveUserStorage),
);
// 以下省略
}
}
関数② _fetchUserFromServer関連の定義
_fetchUserFromServer から発生する例外グループとその詳細の定義は以下です。
saveUser の例外グループの親クラス SaveUserException ではなく、FetchUserException を親に持ちます。
sealed class FetchUserException extends AppException {
FetchUserException(super.type);
}
class FetchUserNetworkException extends FetchUserException {
FetchUserNetworkException(super.type);
}
class FetchUserServerException extends FetchUserException {
FetchUserServerException(super.type);
}
class FetchUserTimeoutException extends FetchUserException {
FetchUserTimeoutException(super.type);
}
class FetchUserUnexpectedException extends FetchUserException {
FetchUserUnexpectedException(super.type);
}
_fetchUserFromServer の戻り値型の型エイリアスです。
成功の場合の戻り値はこちらも同じく User ですが、失敗の場合の Exception の型はFetchUserException です。
また、型エイリアス名はこのファイル内でしか使わないのでアンダースコアをつけた、プライベートな命名としています。
typedef _FetchUserResult = Result<User, FetchUserException>;
_fetchUserFromServer の実装です。
/// サーバーからユーザー情報を取得する(プライベートメソッド)
Future<_FetchUserResult> _fetchUserFromServer(
String id,
) async {
try {
// APIを使ってサーバーからデータを取得していると仮定
final user = await _remoteDataSource.getUser(id);
return Result.success(user);
} on NetworkException {
return Result.failure(
FetchUserNetworkException(AppExceptionType.fetchUserNetwork),
);
// 以下省略
}
}
二つの例外グループを束ねる例外グループを定義する
まずは、_fetchUserFromServer が投げる FetchUserException と、
saveUser が投げる SaveUserException をひとまとめに扱うための
新しい例外グループ GetUserException を定義します。
sealed class GetUserException implements Exception {
const GetUserException();
}
/// リモート取得中に発生したエラー(中身に FetchUserException を丸ごと持つ)
class GetUserFetchException extends GetUserException {
const GetUserFetchException(this.cause);
final FetchUserException cause;
}
/// ローカル保存中に発生したエラー(中身に SaveUserException を丸ごと持つ)
class GetUserSaveException extends GetUserException {
const GetUserSaveException(this.cause);
final SaveUserException cause;
}
ここでのポイントは、GetUserException が
AppException ではなく 生の Exception を実装している ところです。
SaveUserException / FetchUserException はどちらも
共通の親である AppException を継承していましたが、
GetUserException はそれらを「まとめて表現するためのラッパー」の役割にとどめたいので、
AppException ファミリーには参加させていません。
AppExceptionの実装
/// アプリケーション全体で使用する基底例外クラス
abstract class AppException implements Exception {
AppException(this.type);
final AppExceptionType type;
String get prefix => type.prefix;
int get code => type.code;
String get message => type.message;
String toString() => '[$prefix] $code: $message';
}
代わりに、各サブクラスが
-
GetUserFetchException…FetchUserExceptionをそのままcauseとして持つ -
GetUserSaveException…SaveUserExceptionをそのままcauseとして持つ
という形で、「元の例外グループ」をフィールドに保持しています。
もし GetUserException 自体を AppException のサブクラスにしてしまうと、
- どのレイヤーでも
AppExceptionの網羅チェックに -
GetUserExceptionまで含める必要が出てくる
など、関係の薄い箇所にまで影響範囲が広がってしまう可能性があります。
そのためここでは、
AppException …… アプリ全体で使う共通の大きな例外のグループ
GetUserException …… getUser 専用の、「Fetch と Save を束ねるための小さなグループ」
という役割分担にしておき、
GetUserException 側は「ラッパーとして元の例外を持つだけ」と割り切っています。
このように sealed class を使うことで、
必要な粒度ごとに例外グループを柔軟に切り分けつつ、
それぞれに異なるフィールド(ここでは cause)を持たせることができます。
getUserの戻り値を型エイリアスで定義する
次に他の関数と同じように可読性の観点から以下のように型エイリアスを定義します。
typedef GetUserResult = Result<User, GetUserException>;
getUserを定義する
上記を踏まえてswitch 文で素直に実装すると、以下のようになります。
/// 指定されたIDのユーザー情報を取得する
///
/// Result型を使ってシンプルに実装したバージョン
// ignore: non_constant_identifier_names
Future<GetUserResult> getUser_ver1(String id) async {
// 1. サーバーから取得
final fetchResult = await _fetchUserFromServer(id);
switch (fetchResult) {
// 2. 取得成功時はローカル保存へ
case Success(data: final user):
final saveResult = await saveUser(user);
return switch (saveResult) {
// 3. 保存成功時はユーザー情報を返す
Success(data: final savedUser) => Result.success(savedUser),
// 4. 保存失敗時はSaveUserExceptionをGetUserSaveExceptionにラップして返す
Failure(error: final saveError) =>
Result.failure(GetUserSaveException(saveError)),
};
// 5. 取得失敗時はFetchUserExceptionをGetUserFetchExceptionにラップして返す
case Failure(error: final fetchError):
return Result.failure(GetUserFetchException(fetchError));
}
}
やっていること自体は、コメントの番号の通りとても素直です。
-
_fetchUserFromServerでサーバーからユーザーを取得する - 成功したら、そのユーザーを
saveUserに渡してローカル保存する - 保存まで成功したら、そのユーザー情報を
Result.successで返す - どこかで失敗した場合は、それぞれの例外を
GetUserExceptionにラップして返す
これで、
- 「サーバーから取得してローカルに保存し、その結果を返す
getUser関数」 - 「失敗時の例外グループは
GetUserExceptionに統一」
という、当初のゴールは一通り達成できています。
ただしコードを見て分かる通り、
-
fetchResultのswitchの中に - さらに
saveResultのswitchがネストしている
という構造になっており、Result を返す関数が増えていくと
このネストがどんどん深くなってしまう、という問題があります。
if-case文を使ってネストを軽減してみる
上記のような式のネストは可読性を下げてしまいます。
そこでif-case文を使ってみると以下のようにネストを抑えることができます。
/// 指定されたIDのユーザー情報を取得する
///
/// Result型を使って、かつif-case文でネストを抑えたバージョン
// ignore: non_constant_identifier_names
Future<Result<User, GetUserException>> getUser_ver2(String id) async {
// 1. サーバーから取得
final fetchResult = await _fetchUserFromServer(id);
// 2. 取得失敗時はFetchUserExceptionをGetUserFetchExceptionにラップして返す
if (fetchResult case Failure(error: final fetchError)) {
return Result.failure(GetUserFetchException(fetchError));
}
// 3. ここに到達した時点でfetchResultはSuccessのはずだが、
// コンパイラはそれを認識できないのでキャストが必要
final user = (fetchResult as Success<User, FetchUserException>).data;
final saveResult = await saveUser(user);
// 4. 最後なので通常のswitchで判定
return switch (saveResult) {
// 5. 保存成功時はユーザー情報を返す
Success(data: final savedUser) => Result.success(savedUser),
// 6. 保存失敗時はSaveUserExceptionをGetUserSaveExceptionにラップして返す
Failure(error: final saveError) =>
Result.failure(GetUserSaveException(saveError)),
};
}
処理の流れ自体は getUser_ver1 とまったく同じです。
大きな違いは、fetchResult の扱い方です。
最初に
if (fetchResult case Failure(error: final fetchError)) {
return Result.failure(GetUserFetchException(fetchError));
}
という if-case 文で「失敗のパターンだけ」を先に取り出してしまい、
その場で早期リターンしている点がポイントです。
こうして取得時の失敗パターンを最初に排除しておくことで、
その後の処理では「成功した User を使って saveUser を呼ぶ」ことだけに集中できます。
一方で少し気になるのが、成功した値を取り出すときのこの行です。
final user = (fetchResult as Success<User, FetchUserException>).data;
if-case で Failure を弾いているにもかかわらず、
現在の Dart では「この時点では fetchResult は Success である」と
コンパイラが推論してくれないため、自分で Success<..., ...> へのキャストを書いてあげる必要があります。
このキャスト部分が少しノイズになってしまうので、
次の章ではこのあたりをもう少しスッキリ書ける 関数型スタイル を試していきます。
関数型スタイルで可読性を向上させる
ここまでで紹介した、switch や if-case を使った手続き型の書き方を使えば、
複数の Result 型を組み合わせた処理自体は問題なく書くことができます。
しかし、どうにも可読性が高いとは言いづらいのも事実です。
上記の例は Result 型を返す関数が 2つだけだったのでまだマシですが、
これが 3つ、4つと増えていくと、さらに読みづらさは増していきます。
そこで次に、関数型スタイル を取り入れた書き方に置き換えてみたいと思います。
関数型スタイル
世の中のプログラミングの世界には大雑把にいって 手続き型 と 関数型 の二つのスタイルがあります。
従来の書き方である 手続き型 とは、getUser_ver1 でも示した通り、
変数の宣言、if文やswitch文、try-catchなどを書いて、
上から順番に処理をこなしていく形です。
一方で本来の 関数型プログラミング はもっと広い概念ですが、
この記事ではそこまで厳密な話はせずに、
「ある値に対して
mapやflatMapなどの関数を
メソッドチェーンでつなげて処理していく書き方」
をざっくりと 関数型スタイル と呼ぶことにします。
このスタイルでは、fetch → save → 加工 → 返却 のような一連の処理を、
if や switch を多用せずに「鎖のように」つなげて書けるため、
慣れると可読性と記述速度が向上すると言われています。
Result型に変換用の関数を定義する
前回の記事でfreezedを使って定義したResult 型に以下のプライベートコンストラクタを定義します。
sealed class Result<T, E extends Exception> with _$Result<T, E> {
const Result._(); // <<<<<< これを追加🔥🔥🔥🔥
/// 成功
const factory Result.success(T data) = Success<T, E>;
/// 失敗
const factory Result.failure(E error) = Failure<T, E>;
// 以下に変換メソッドを追加していく
}
freezed で生成されるクラスに対して インスタンスメソッド を追加したい場合、
このように「プライベートコンストラクタ(Result._)」を定義しておく必要があります。
これにより、
-
Result自体はconst Result.success(...)/const Result.failure(...)でしか作れない(設計どおり) - かつ、その
Result型のインスタンスに対して
result.mapError(...)のような メソッド呼び出しを書ける
という状態を作ることができます。
mapError 例外クラスを別の例外クラスに変換する
まずは「Result の エラー側だけ を別の型に変換する」ための関数を定義します。
/// エラー型を別の型に変換
Result<T, E2> mapError<E2 extends Exception>(
E2 Function(E error) convert,
) {
return switch (this) {
Success(:final data) => Result<T, E2>.success(data),
Failure(:final error) => Result<T, E2>.failure(convert(error)),
};
}
ここでやっていることはとてもシンプルで、
- 成功側 T はそのまま
- 失敗側 E を、convert 関数を使って別の例外 E2 に変換する
というだけです。
今回の例だと、
- _fetchUserFromServer が返す Result<User, FetchUserException>
- getUser 全体としては Result<User, GetUserException> を返したい
という関係があるので、
fetchResult.mapError<GetUserException>(GetUserFetchException.new)
とすることで、
- 成功: User はそのまま
- 失敗: FetchUserException を GetUserFetchException にラップして GetUserException に揃える
という変換が 1 行で書けるようになります。
このイメージだけ頭の片隅に置いておいてもらえれば十分で、
詳細な使い方はこのあと getUser_ver3 以降のコードで具体的に見ていきます。
次の処理に繋げる flatMap と asyncFlatMap
次に、「ある Result が成功したときだけ、次の Result 処理につなげる」ための関数を用意します。
flatMap が同期関数、asyncFlatMap が非同期関数を扱う場合に用いるものです。
/// 成功時に別のResult処理を実行
Result<R, E> flatMap<R>(Result<R, E> Function(T data) transform) {
return switch (this) {
Success(:final data) => transform(data),
Failure(:final error) => Result<R, E>.failure(error),
};
}
/// 成功時に非同期のResult処理を実行
Future<Result<R, E>> asyncFlatMap<R>(
Future<Result<R, E>> Function(T data) transform,
) async {
return switch (this) {
Success(:final data) => transform(data),
Failure(:final error) => Future.value(Result<R, E>.failure(error)),
};
}
どちらも共通しているのは、
-
this(元のResult)がSuccessのときだけtransformを呼び出す -
Failureのときはtransformを呼ばず、そのままエラーを返す
という振る舞いです。
今回の getUser の例だと、
- まず
_fetchUserFromServerのResultを受け取り - 成功したときだけ
saveUser(ローカル保存)のResultにつなぐ
という使い方をします。
変換用関数を用いた簡単なリファクタリング
Result に定義した関数を使って、getUser_ver1 を少しだけスッキリさせたのが次の例です。
/// 指定されたIDのユーザー情報を取得する
///
/// Result型のメソッドを使ってswitchのネストを避けるバージョン
// ignore: non_constant_identifier_names
Future<GetUserResult> getUser_ver3(String id) async {
// 1. サーバーから取得
final fetchResult = await _fetchUserFromServer(id);
// 2. FetchUserExceptionをGetUserFetchExceptionにラップ
final convertedFetch =
fetchResult.mapError<GetUserException>(GetUserFetchException.new);
// 3. flatMapでローカル保存する
return convertedFetch.asyncFlatMap((user) async {
final saveResult = await saveUser(user);
return saveResult.mapError<GetUserException>(GetUserSaveException.new);
});
}
それぞれのステップを言い換えると、次のようになります。
- _fetchUserFromServer でサーバーからユーザーを取得する
- その結果の「エラー側」だけを
FetchUserException→GetUserFetchExceptionに変換し、GetUserExceptionに揃える - 成功していれば
asyncFlatMap内でsaveUserを呼び、そこでもmapErrorを使ってSaveUserException→GetUserSaveExceptionに変換する
つまり、やっていること自体は getUser_ver1 / getUser_ver2 と同じで、
取得 → 取得時のエラー変換 → 保存 → 保存時のエラー変換
という流れです。
違いは、switch 文やネストした分岐を書かずに、
- 「エラー変換」→
mapError - 「次の
Result処理につなげる」→asyncFlatMap
という形で、Result 側のメソッドとして処理を委ねている 点です。
ただし、この時点ではまだ
-
fetchResultという変数を用意して… - そこから
mapErrorを呼び出して…
というように、ある程度 手続き型の書き方 も残っています。
ここからさらに一歩進めて、もう少し「関数型スタイル寄り」の書き方も試してみます。
もう少し関数型スタイルにしていく
getUser_ver3 との違いは、fetchResult に対して直接 mapError をドットで書いて、さらにそこからドットで asyncFlatMapを書いている部分です。
/// 指定されたIDのユーザー情報を取得する
///
/// もう少しメソッドチェーンで繋げるバージョン
// ignore: non_constant_identifier_names
Future<GetUserResult> getUser_ver4(String id) async {
// 1. サーバーから取得
final fetchResult = await _fetchUserFromServer(id);
// 2. FetchUserExceptionをGetUserFetchExceptionにラップし、
// さらにflatMapでローカル保存をチェーンし、SaveUserExceptionもラップ
return fetchResult
.mapError<GetUserException>(GetUserFetchException.new)
.asyncFlatMap((user) async {
final saveResult = await saveUser(user);
return saveResult.mapError<GetUserException>(GetUserSaveException.new);
});
}
拡張を使って可読性を上げる
getUser_ver4 の mapError をかけている部分を拡張することで、内部処理を隠蔽してしまうとさらに可読性が上がります。
extension on _FetchUserResult {
GetUserResult toGetUserResult() =>
mapError<GetUserException>(GetUserFetchException.new);
}
extension on SaveUserResult {
GetUserResult toGetUserResult() =>
mapError<GetUserException>(GetUserSaveException.new);
}
/// 指定されたIDのユーザー情報を取得する
///
/// 拡張も使って読みやすくしたバージョン
// ignore: non_constant_identifier_names
Future<GetUserResult> getUser_ver5(String id) async {
// 1. サーバーから取得
final fetchResult = await _fetchUserFromServer(id);
// 2. FetchUserExceptionをGetUserFetchExceptionにラップし、
// さらにflatMapでローカル保存をチェーンし、SaveUserExceptionもラップ
return fetchResult.toGetUserResult().asyncFlatMap((user) async {
final saveResult = await saveUser(user);
return saveResult.toGetUserResult();
});
}
完全にメソッドチェーンだけで定義する
_fetchUserFromServerの結果を変数に入れずに、完全にメソッドチェーンで繋げると以下のようなアロー関数としてかけてしまいます。
/// 指定されたIDのユーザー情報を取得する
///
/// 究極的に短く書くパターン
// ignore: non_constant_identifier_names
Future<GetUserResult> getUser_ver6(String id) async =>
(await _fetchUserFromServer(id)).toGetUserResult().asyncFlatMap(
(user) async => (await saveUser(user)).toGetUserResult(),
);
ここまで短く書くことは可能ですが、デバッグや途中でログを挟みたいときは読みづらくなってしまいます。
よく知られている言葉に、
Good programmers write code that humans can understand. (Martin Fowler)
「どんな愚か者でもコンピュータが理解できるコードは書ける。良いプログラマは人間が理解できるコードを書く。」
Programs must be written for people to read, and only incidentally for machines to execute. (SICP(Abelson & Sussman))
「プログラムは人が読むために書かれるべきであり、機械が実行するのはその副産物に過ぎない。」
というものがありますが、まさに「短ければ正義」ではなく、
人間が読んで理解しやすいことのほうを優先したいところです。
終わりに
この記事では、
-
Result型 ×sealed class×AppExceptionという前回の土台を引き継ぎつつ - 異なるエラー型(
FetchUserException/SaveUserException)を -
GetUserExceptionという「小さな例外グループ」でまとめ、 -
mapError/flatMap/asyncFlatMapや拡張メソッドで関数型スタイルにつないでいく
という流れをコードで追いかけてきました。
押さえておきたいポイントは次の3つです。
- 複数の
Resultを組み合わせるときは、「例外グループをどう切るか」もセットで設計する mapError/flatMapを用意すると、if/switchのネストを共通処理に押し出せる- 「短く書く」ことより、「チームが読みやすい落としどころ」を優先する
もし自分のコードにネスト地獄を感じているなら、
- まずは
Resultを返す関数が 2 つつながっている箇所を 1 か所だけ選ぶ - そこに
mapError/flatMapを導入してみる - 必要に応じて、その処理専用の例外グループを
sealed classで切る
といった 小さなリファクタリング から試してみるのがおすすめです。
この記事で使ったサンプルコードは GitHub に置いてあります。
自分のプロジェクトに合わせて、型名や例外グループを変えながら、ぜひ手を動かしてみてください。
Discussion