Dart 3の真髄:switch式とResult型で築く、例外ゼロの堅牢なエラーハンドリング
コードレビューをしていて、いまだに広範囲で `try-catch` を乱用しているコードに出会うと、私はこう問いたくなる。「その例外、本当に予測不可能な異常系か? それともビジネスロジックの一部ではないのか?」と。
JavaやJavaScriptの文化を引きずったままの `try-catch` によるエラーハンドリングは、コールスタックを巻き戻すランタイムコスト(スタックトレースの生成コスト)を支払うだけでなく、「何が返ってくるのか」を型システムから完全に隠蔽する。関数シグネチャを見ただけでは、呼び出し側がどの例外に備えるべきかコンパイラが教えてくれない。これは静的型付け言語に対する冒涜であり、バグの温床だ。
Dart 3で導入されたパターンマッチングとswitch式は、この課題に対する決定的な解を提供する。
今回は、DartのコンパイラとVMの挙動を知り尽くしたアーキテクトの視点から、`Result型` と `switch式` を組み合わせた、例外を投げないモダンで堅牢なエラーハンドリングの極限パターンを伝授する。
—
なぜ `try-catch` と例外スローは悪なのか?
1. 型シグネチャの嘘: `Future
2. 制御フローの破壊: 例外は非局所的なジャンプ(Non-local jump)を引き起こす。コードの可読性を著しく下げ、リアクティブなストリームや非同期処理のパイプラインにおいてバグを生みやすい。
3. VMの最適化阻害: 頻繁な例外のスローとキャッチは、Dart VM(JIT/AOT)の最適化パスにおいてコストが高い。特にホットパスでの例外利用はパフォーマンスの致命傷になる。
これらを解決するのが、エラーを「値(Value)」として扱う Result型(Either型の一種) だ。
—
プロダクション品質:Result型とswitch式の完全実装
まずは、余計なサードパーティ製パッケージに頼らず、Dart 3の言語機能を極限まで活かした `Result
import ‘package:meta/meta.dart’;
/// 型安全なResult型。不変(immutable)であり、Dart 3の網羅的switch式の対象となる。
@immutable
sealed class Result
const Result();
/// 成功時の値をラップするコンストラクタ
const factory Result.success(T data) = Success
/// 失敗時のエラーをラップするコンストラクタ
const factory Result.failure(E error) = Failure
/// 共通のマップ関数(関数型パイプライン用)
R map
required R Function(T data) success,
required R Function(E error) failure,
}) {
return switch (this) {
Success(data: final d) => success(d),
Failure(error: final e) => failure(e),
};
}
}
final class Success
final T data;
const Success(this.data);
@override
bool operator ==(Object other) =>
identical(this, other) || other is Success
@override
int get hashCode => data.hashCode;
}
final class Failure
final E error;
const Failure(this.error);
@override
bool operator ==(Object other) =>
identical(this, other) || other is Failure
@override
int get hashCode => error.hashCode;
}
この設計のポイント(アーキテクトの知見)
- `sealed` 修飾子: これが極めて重要。`sealed class` を使うことで、Dartコンパイラは「このクラスを継承できるのは同一ファイル内のクラスのみ」と断定できる。これにより、後述する `switch` 式で `default` 句を書かなくても、網羅性チェック(Exhaustiveness checking) が完全に効くようになる。将来エラーの種類が増えた際、コンパイラが全てのハンドリング漏れをビルド時に検知してくれる。
- `@immutable`: 状態の不変性を保証し、FlutterのウィジェットツリーやProvider/Blocなどの状態管理において不要な再描画やバグを防ぐ。
—
実践:APIクライアントとUI層でのパターンマッチング
では、この `Result` 型を実際のWeb/Flutterフロントエンドのユースケース、すなわち「API通信・ドメインエラー処理」にどう aplicar(適用)するか。コードを見ていこう。
// ドメイン固有のエラー定義
sealed class ApiError {
const ApiError();
}
class NetworkError extends ApiError { const NetworkError(); }
class UnauthorizedError extends ApiError { const UnauthorizedError(); }
class ServerErrorFactors extends ApiError {
final int code;
const ServerErrorFactors(this.code);
}
// リポジトリ層:例外を投げず、Resultを返す
Future
try {
// 擬似的なHTTPリクエスト
final response = await httpClient.get(‘/users/$userId’);
if (response.statusCode == 401) {
return const Result.failure(UnauthorizedError());
} else if (response.statusCode >= 500) {
return Result.failure(ServerErrorFactors(response.statusCode));
}
final user = User.fromJson(response.data);
return Result.success(user);
} catch (_) {
// 予期せぬ通信切断などはここでキャッチし、ドメインエラーに変換
return const Result.failure(NetworkError());
}
}
UI層・コンポーネントでの `switch` 式による分岐
呼び出し側(UIまたはBLoC/ViewModel)では、Dart 3の switch式 を用いて、美しくかつ安全に処理を分岐させる。
String renderUserScreen(Result
// Dart 3 の switch式による網羅的パターンマッチング
return switch (userResult) {
// 成功パターン:構造化パターンで直接データを取り出す
Success(data: final user) => ‘ようこそ、${user.name}さん!’,
// 失敗パターン:エラーの種類に応じて完全に網羅して処理
Failure(error: NetworkError()) => ‘ネットワーク接続を確認してください。’,
Failure(error: UnauthorizedError()) => ‘セッションが切れました。再ログインしてください。’,
Failure(error: ServerErrorFactors(code: final c)) => ‘サーバーエラーが発生しました (Code: $c)’,
};
}
このコードの美しさは、if-elseのネストが完全に消滅している点にある。さらに、もし将来 `ApiError` に `MaintenanceError` を追加した場合、コンパイラは即座にこの `switch` 式でコンパイルエラーを吐き、開発者に対応を強制する。ヒューマンエラーの余地が入り込む隙がない。
—
パフォーマンス上の注意点:AOTコンパイルとアロケーション
ここで、チーフアーキテクトとしてパフォーマンスに関する重要な警告をしておこう。
Result型はクラス(ヒープオブジェクト)であるため、毎回のAPI呼び出しで `Success` や `Failure` のインスタンスがアロケート(生成)される。Flutterのパフォーマンスチューニング(60/120fpsの維持)において、ガベージコレクション(GC)の負荷を最小化することは至上命題だ。
1. 定数コンストラクタの活用:
前述のコードで `const Result.failure(UnauthorizedError())` のように `const` を付与しているのは伊達ではない。エラーがステートレスである場合、これらを `const` インスタンスとしてキャッシュ・再利用することで、ヒープアロケーションをゼロにできる。
2. ホットパスでの過剰なラップに注意:
毎フレーム実行される描画ロジックや、数万回ループする数学的計算の中でResult型を返すのは避けるべきだ。そうした領域では、従来どおりプリ型や例外、あるいは null許容型(`T?`)の活用を検討する。しかし、I/Oバウンドな非同期処理(API, DB, ファイル操作)においては、Result型による安全性の方がアロケーションコストを圧倒的に上回るメリットを持つ。
—
まとめ:コードレビューで明日から使えるチェックリスト
チームにこのパターンを導入する際、以下の基準をコードレビューの共通認識としてほしい。
- [ ] 例外(`throw` / `try-catch`)をビジネスロジックやAPI層で使用していないか?(OSの致命的なOOMやアサーション違反を除き、制御フローとしての例外は排除する)
- [ ] 非同期関数の戻り値が `Future
>` になっているか? - [ ] エラー型(`E`)および Result型自体が `sealed` かつ `immutable` に設計されているか?
- [ ] 処理の分岐に `switch` ステートメントではなく、戻り値を受け取る `switch` 式 を用いているか?
Dart 3のポテンシャルをフルに引き出し、保守性・堅牢性が極限まで高められたコードベースを構築してほしい。君たちのプロダクトから、例外に起因するバグが駆逐されることを期待している。