【Flutter】Result型 × sealed class × AppException で実現する安全なエラーハンドリング設計
はじめに
今まで私はエラーハンドリングについて、大いに悩んできました。
Dart では基本的に try-catch-throw の流れで例外を処理しますが、
- どの関数がどんな例外を投げるのかが型からは分からない
- 実装を追いかけないと例外の一覧が把握できない
- 複数人開発になると、例外処理の方針や粒度が人によってバラバラになる
といった問題があり、プロダクトが大きくなるほど
「例外の考慮漏れ」や「キャッチアップコスト」がじわじわ効いてきます。
この記事では、こうした問題を解決する手段として、表題にも挙げた
Result 型 × sealed class × AppException
の 3 つを組み合わせた設計で、
- 例外を「投げる」のではなく「戻り値として返す」
- 「どこで」「どんなエラーが」起こり得るかを型で表現する
といった、安全で追跡しやすいエラーハンドリングの仕組みを
具体的なコード例とともに解説していきます。
記事の対象者
- Flutter / Dart で開発していて、例外処理(try-catch)がだんだんカオスになってきたと感じている人
- 「とりあえず throw Exception」としてきたけれど、
プロダクトが大きくなってきてエラーハンドリングの設計をちゃんと見直したい人 -
Result型 やsealed class、AppExceptionのような設計に興味があり、
「概念の説明だけでなく、実際のコード例も見ながら理解したい」Flutter エンジニア - 中〜大規模な Flutter アプリで、
「UI からドメイン・リポジトリまで一貫したエラー設計」を整えたい人
記事を執筆時点での筆者の環境
[✓] 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を使っていますので、その前提でのコードになっています。
この点については解説していませんので、ご了承ください。
過去に書いた記事で解説していますので、よろしければそちらをご覧ください。
try-catchのみのハンドリングの問題点
Dart の関数は、実行中にどんな例外が発生し得るのかを
型システムで表現できません(例外が型に現れません)。
そのため開発者は、
- ドキュメントコメントを読む
- 実装を定義ジャンプして確認する
といった方法で、どの例外が飛ぶのかを逐一手作業で追う必要があります。
小さな関数だけならまだしも、実際のプロダクトでは
- UI → Service → Repository → DataSource → ネットワーク層
- UI → Domain → UseCase → Repository → キャッシュ層
と“呼び出し元から実装元までが深い” ことが多く、
例外の流れを追跡するだけで相当なコストがかかります。
さらに複数人開発では、既存関数を組み合わせて新しいメソッドを作る際に、
- 例外の見落とし
- 存在に気付かない例外の取りこぼし
- try-catch の粒度・扱い方が人によって違う
といったことで不具合が発生する可能性があります。
プロダクトが大きくなるほど、
例外の管理・把握・統一した処理方針の維持は難しくなり、
気付かぬうちに開発コストとリスクが跳ね上がってしまいます。
解決に使う3つの手段
ここまで紹介した問題は、
「例外が型に現れず、どこで何が起きるか分からない」
という Dart の特徴によるものです。
これを安全に扱うために、この記事では次の3つの仕組みを組み合わせます。
- sealed class(言語仕様)
状態を安全に列挙し、パターンマッチによって網羅的に処理できる。 - Result型(実装パターン)
成功と失敗を明確に型として返し、例外を戻り値で扱えるようにする。 - AppException(アプリ固有の設計)
アプリ内のあらゆるエラーを共通の型で管理するための基底クラス。
これらを組み合わせることで、
例外を“投げる”のではなく“返す”設計に統一でき、
どこで何が起こり得るかが型で分かる安全なアプリケーション設計
を実現できます。
それぞれの仕組みが何をするものかを順番に解説します。
sealed class
sealed class は Dart 3.0 から導入されたクラス修飾子です。
用途としては enum と同じく 「特定の種類をまとめて扱えるようにする」 ことと
パターンマッチを安全に使えるようにする ことが目的です。
enum と違う点は、
- クラスなのでフィールドやメソッドを持てる
- 他のクラスを継承できる(サブクラスを作れる)
- ただし 同じファイル内でしかサブクラスを作れない(外部継承は禁止)
という特徴があります。
これにより、
- ある親クラス(sealed)に対して
- 子クラス(case)を安全に網羅チェックでき
- かつ、子クラスごとに値や振る舞いを持てる
という柔軟かつ安全なモデルを作ることができます。
簡単な例を見てみましょう。
// sealed class を定義(同じファイル内でのみサブクラスを作れる)
sealed class Shape {}
class Circle extends Shape {
final double radius;
Circle(this.radius);
}
class Rectangle extends Shape {
final double width;
final double height;
Rectangle(this.width, this.height);
}
class Triangle extends Shape {
final double base;
final double height;
Triangle(this.base, this.height);
}
// パターンマッチで網羅チェックができる
double calculateArea(Shape shape) {
return switch (shape) {
Circle(radius: final r) => 3.14 * r * r,
Rectangle(width: final w, height: final h) => w * h,
Triangle(base: final b, height: final h) => 0.5 * b * h,
// ↑ 全てのサブクラスを網羅しないとコンパイルエラーになる
};
}
この例では Shape という親クラスに対して、Circle、Rectangle、Triangle という子クラスを定義しています。switch 式で全てのケースを網羅しないとコンパイルエラーになるため、新しいサブクラスを追加した際に対応漏れを防ぐことができます。
後述する Result 型も、この sealed class を使ってSuccess と Failure をまとめて扱えるようにしている仕組みです。
Result型
Result 型は処理結果を 成功(Success) と 失敗(Failure) の
2 種類に分けて表現するための型です。
Dart には標準で Result 型は存在しませんが、
sealed class を使うことで安全に実装できます。
Result 型を返す関数は、
「この関数は成功だけでなく失敗も返す可能性がある」
ということを型によって明示できます。
そのため、呼び出し側は Success と Failure の両方を
必ずハンドリングしなければならず、漏れがなくなります。
この考え方は Dart だけのものではなく、
以下のように多くのモダン言語で採用されている概念です。
- Swift (
Result<T, Error>) - Kotlin (
Result<T>) - Rust (
Result<T, E>)
特に Rust の Result は非常に強力で、
「例外を使わず Result 型で処理を連鎖させる文化」を持っており、
関数の戻り値でエラーを明示する設計の中心になっています。
AppException
Dart のあらゆる例外は Exception を継承して作られていますが、
そのまま扱うと「どんな種類のエラーなのか」「画面に何を表示すればいいのか」
といった情報が不足しています。
そこでアプリ側では、全ての例外を共通の AppException にラップして扱うようにします。
- エラーコード
- ユーザー向けメッセージ
- ログ出力用の識別子
- ハンドリング方針(再試行すべきか、致命的か など)
といったアプリ固有の情報を AppException にまとめておくことで、
- 例外の扱い方を 統一できる
- 例外ごとの 対応漏れが防げる
- UI 層が「何を表示すべきか」判断しやすい
- 監視・アラート・ログ分析がやりやすくなる
というメリットがあります。
さらに、外部ライブラリや SDK が投げる生の Exception を
そのまま上位に流すことを禁止し、必ず AppException に変換することで
「想定外の例外が UI まで飛ぶ」という事故も防げます。
具体的な実装
ここからは上記を組み合わせて具体的にどのように実装していくかを解説していきます。
Result型を定義する
Result型はそのまま sealed class として定義することもできますが、今回はパッケージの freezed を使って定義します。
Freezed を使うと == や toStringを自動生成してくれるので、Result 型との相性がとても良いです。
/// 処理の成功・失敗を表すResult型
@freezed
sealed class Result<T, E extends Exception> with _$Result<T, E> {
/// 成功
const factory Result.success(T data) = Success<T, E>;
/// 失敗
const factory Result.failure(E error) = Failure<T, E>;
}
重要なのはクラスのジェネリクスの指定方法です。
- T
正常に処理できた場合の戻り値の型を表します。 - E extends Exception
失敗時の例外を表す型で、Exceptionを継承しているものだけを許可しています。
こうすることで、アプリのロジックでハンドリングすべき“業務エラー”だけを扱い、
StackOverflowErrorなど本来ハンドリングすべきでないError系をResultに混ぜないよう制約をかけています。
アプリで扱うExceptionをAppExceptionで定義する
次にアプリで扱う Exception の規定クラスを 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;
@override
String toString() => '[$prefix] $code: $message';
}
ポイントは2つあります。
-
abstract修飾子によって、このクラス単体のインスタンス化を禁止すること。
AppExceptionは「アプリ内の例外の共通の親」として使うためのもので、
直接投げるためのものではありません。
具体的な例外(SaveUserPermissionExceptionなど)は必ずサブクラスとして作らせることで、
エラーの意味や発生箇所があいまいになることを防ぎます。 - 付随情報を
AppExceptionTypeにまとめ、例外の定義を一元管理すること。
例外名・コード・メッセージなどをすべてenumにまとめておくと、
- コードの重複防止
- 文言の改修がしやすい
- “例外を追加するときは
enumに書かないといけない” という強制力
が生まれ、プロダクト全体で例外設計が統一されます。
特にエラーコードは、ユーザーが問い合わせ時に伝える際など、
ユニークな値である必要があり、enum に閉じ込める設計と相性抜群です。
/// エラー定義を一元管理するEnum
enum AppExceptionType {
// 一部抜粋
// ...
saveUserStorage(
prefix: 'SaveUserStorageException',
code: 2001,
message: 'ストレージ容量が不足しています',
),
saveUserPermission(
prefix: 'SaveUserPermissionException',
code: 2002,
message: '書き込み権限がありません',
),
saveUserUnexpected(
prefix: 'SaveUserUnexpectedException',
code: 2099,
message: '予期しないエラーが発生しました',
),
// ....
;
const AppExceptionType({
required this.prefix,
required this.code,
required this.message,
});
final String prefix;
final int code;
final String message;
}
関数の例外を sealed class でグルーピングする
次に関数が返す可能性のある例外を sealed class でグルーピングしていきます。
今回は UserRepository の saveUser という関数の例外をグルーピングします。
/// グルーピングの親でAppExceptionを継承しておく
sealed class SaveUserException extends AppException {
SaveUserException(super.type);
}
// 以下は親のSaveUserExceptionを継承した子クラス==AppExceptionも継承している
class SaveUserStorageException extends SaveUserException {
SaveUserStorageException(super.type);
}
class SaveUserPermissionException extends SaveUserException {
SaveUserPermissionException(super.type);
}
class SaveUserUnexpectedException extends SaveUserException {
SaveUserUnexpectedException(super.type);
}
まずは親である SaveUserException を定義しますが、この時にAppException を継承させることで、必然的に後に続く子クラスである SaveUserStorageException 達は 強制的にAppExceptionである必要があります。
リポジトリのメソッドをResult型を使って定義する
下準備は整いました。
ここからは上記で用意した Result 型と sealed class SaveUserException を使った関数を実装していきます。
まず、関数の戻り値ですがそのまま書くと長くなってしまうので型エイリアスを使って別名で定義しておきます。
こうしておくと、型定義が読みやすくなるだけでなく、 「saveUser 関数は SaveUserResult を返す」 という意図もコード上ではっきり表現できるようになります。
/// SaveUserの戻り値型
typedef SaveUserResult = Result<User, SaveUserException>;
Result<User, SaveUserException> のジェネリクスは次のような意味になります。
- 左側の
User
正常に処理が完了した場合に返す値の型 - 右側の
SaveUserException
失敗した場合に返す例外の型(グルーピングの親)- 子クラスである
SaveUserStorageExceptionや
SaveUserPermissionExceptionなどもすべてここに含まれます
- 子クラスである
次に上記の型を戻り値にもつ関数、saveUser(User user)を以下のように定義していきます。
class UserRepository {
/// ユーザー情報をローカルに保存する
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),
);
} on PermissionException {
return Result.failure(
SaveUserPermissionException(AppExceptionType.saveUserPermission),
);
} on Exception {
return Result.failure(
SaveUserUnexpectedException(AppExceptionType.saveUserUnexpected),
);
}
}
// ...
}
まず、リポジトリ層では通常通り try-catch で例外処理を行います。
- 正常系
保存に成功した場合は、そのままResult.success(user)を返します。 - 異常系
例外をそれぞれのcatchで受け取り、対応するAppException
(SaveUserStorageExceptionなど)に変換して
Result.failure(exception)として返します。
ここでの注意点としては最後の on Exception{} の部分です。全てのExceptionを考慮できているのであれば必要ないですが、少しでも不明なものや一旦は雑多に扱いたいなどの理由がある場合は「不明なエラー」としてcatch して変換することをお勧めします。
また、ここでは変換する Exception に対して、AppExceptionType を
catch している場所であえて渡す ようにしています。
} on StorageException {
return Result.failure(
SaveUserStorageException(AppExceptionType.saveUserStorage),
);
// ...
これは、実際の運用で「エラーコードから発生箇所を素早く特定する」ためです。
たとえば、プロダクトのお問い合わせや Firebase Crashlytics のログでは、
最初にエラーコード(例: 2001)が記載されていることが多いと思います。
- まずエディタでそのエラーコード
2001を検索すると、
対応するenum AppExceptionType.saveUserStorageの定義が見つかる - 次に
saveUserStorageの参照元をたどると、
どのcatchブロックでそのエラーが投げられているか(今回ならsaveUser内)が
すぐに分かる
このように、
- 「どのコードが」
- 「どの enum に対応していて」
- 「最終的にどの関数のどの行で投げられているのか」
を、エディタの検索だけで一直線に追いかけられるようにするために、
AppExceptionType を 例外クラス側に固定せず、実際に変換する箇所で渡す
という設計にしています。
Result型の戻り値を持つ関数を実行した場合のハンドリング
上記の saveUser 関数を、実際に画面側(UI)から呼び出した場合の例です。。
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
// 新規ユーザー(入力されたユーザーと仮定)
const newUser = User(
id: 'generated-id',
name: 'Steven Gerard',
email: 'steven.gerard@example.com',
);
// ユーザー情報の保存処理を実行
final repository = ref.read(userRepositoryProvider);
final result = await repository.saveUser(newUser);
// 結果に応じてメッセージを表示
late final String message;
switch (result) {
case Success(data: final user):
message = 'User ${user.name} created successfully!';
case Failure(error: final error):
final description = switch (error) {
SaveUserStorageException() =>
'💽 Storage Error: ${error.message}',
SaveUserPermissionException() =>
'✍️ Permission Error: ${error.message}',
SaveUserUnexpectedException() =>
'❓ Unexpected Error: ${error.message}',
};
message = 'Error creating user: $description';
}
if (context.mounted == false) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
},
),
saveUser は Result<User, SaveUserException> を返すので、
画面側では switch (result) で
- Success(成功)
- Failure(失敗)
の 2 パターンを 必ず ハンドリングする形になります。
- Success(data: final user)
保存に成功した場合で、user には保存されたユーザー情報が入っています。 - Failure(error: final error)
失敗した場合で、error には SaveUserException(またはそのサブクラス)が入ります。
失敗時はさらに switch (error) を使って、
- ストレージ不足なら SaveUserStorageException
- 権限不足なら SaveUserPermissionException
- それ以外は SaveUserUnexpectedException
というように、エラーの種類ごとにメッセージを出し分けています。
このハンドリングのメリットは次の通りです。
- UI側で try-catch を書かなくてよい
例外はリポジトリ層でResult.failureに変換されているので、
画面側はResultをswitchするだけで
「成功か失敗か」「失敗ならどんなエラーか」を判定できます。 - 失敗時にどんなエラーが来るかが型で分かる
失敗側の型をSaveUserException(sealed class)にしているので、
switch (error) で
SaveUserStorageException/SaveUserPermissionException/SaveUserUnexpectedException
を網羅的にハンドリングできます。
新しいエラー種別を追加した場合も、コンパイルエラーで気づけるため
「ハンドリング漏れ」を防げます。
終わりに
本記事では、Dart / Flutter における「例外が型に現れないつらさ」に対して、
-
Result型で「成功」と「失敗」を明示する -
sealed classで「この関数で起こり得るエラー」をグルーピングする -
AppException/AppExceptionTypeでエラー情報を一元管理する
という 3 つの仕組みを組み合わせたエラーハンドリング設計を紹介しました。
ポイントをあらためてまとめると、次のようになります。
- 例外を「投げる」のではなく「戻り値として返す」ことで、
どこで何が起こり得るかが型から分かる -
SaveUserExceptionのように関数ごとに例外をグルーピングすることで、
「この関数で起こる失敗」を 1 か所に集約できる -
AppExceptionとAppExceptionTypeによって、
エラーコード・メッセージ・ログ用情報を整理して扱える - UI 層では
try-catchではなくswitch (result)/switch (error)で
成功・失敗とエラー種別を網羅的にハンドリングできる
もちろん、この設計が「唯一絶対の正解」というわけではありません。
アプリの規模やチームの好み、既存コードベースとの兼ね合いによっては、
- まずは一部の Repository だけ
Result型にする -
AppExceptionTypeだけ先に導入して、例外の整理から始める - 新しく追加する機能から徐々にこのスタイルに寄せていく
といった段階的な導入でも十分に効果があります。
「とりあえず throw Exception」から一歩踏み出して、
- どの層で
- どんな失敗が起こり得て
- それを誰がどう扱うのか
をコードと型で表現していくと、
少しずつですが例外設計の見通しが良くなっていくはずです。
本記事のサンプルプロジェクトは、
自由にクローンして改造してもらって構いません。
自分のプロジェクトのエラーハンドリングに当てはめながら、
「この関数の例外グループは何だろう?」
「このエラーはどの AppExceptionType に分類されるべきだろう?」
と悩んでみるところから、ぜひ試してみてください。
どこか一部分でも、あなたのプロジェクトのエラーハンドリングを
少しでも楽に・安全にするヒントになれば幸いです。
続き:複数の Result 型をつなぐ話
この記事では「Result 型 × sealed class × AppException」で、
1つの処理のエラー設計にフォーカスして紹介しました。
実際のアプリでは、ここで作った土台を使いながら
「複数の Result 型をどうつなぐか」「ネストをどう抑えるか」
といったところでまた悩みが出てきます。
そのあたりを、mapError / flatMap などの関数型スタイルとあわせて整理した続きの記事も書きました。
よろしければご覧ください。
Discussion