Dart 3パターンマッチングが変えるエラーハンドリング:`try-catch`を駆逐する型安全な`Result型`の設計
テックリードの私だ。コードレビューの現場で、未だにこんなコードを見かけるたびに私は深いため息をつく。
try {
final user = await api.fetchUser(id);
process(user);
} catch (e, st) {
// ログを出して…どうリカバリーするんだっけ?
logger.error(e, st);
}
言っておくが、例外(Exception)による制御フローの乗っ取りは、モダンなアプリケーション開発において「技術的負債の温床」でしかない。どこで何が投げられるのかシグネチャからは一切読み取れず、キャッチし忘れた瞬間にアプリはクラッシュする。
Dart 3で導入された `sealed class` と `pattern matching`(パターンマッチング)、そして `record` 型。これらを組み合わせることで、私たちは「エラーを例外として隠蔽せず、ファーストクラスの値として扱う」堅牢な世界を手に入れた。
今回は、実務のフロントエンド開発やAPI連携において、バグの起しようがない鉄壁の`Result型`アーキテクトニクスを授けよう。
—
なぜ `try-catch` は実務で破綻するのか?
例外機構の本質は「想定外の異常系」のためのものだ。ネットワークの切断、APIの404、バリデーションエラー――これらはビジネスロジック上「起こり得る正常な分岐」にすぎない。
これを `try-catch` で処理すると、以下の致命的な問題が発生する。
1. 型情報の喪失: `catch (e)` の `e` は常に `Object?` であり、静的型安全性が完全に破壊される。
2. 呼び出し元での隠蔽: 関数シグネチャを見ただけでは、その関数がどんなエラーを返すのかコンパイラが教えてくれない。
3. 網羅性チェックの欠如: 新しいエラー種別を追加しても、コンパイラは `catch` 側の修正漏れを検知できない。
Dart 3の網羅的パターンマッチングは、これらの問題を一刀両断する。
—
実装:プロダクションクオリティの `Result`
まずは、コンパイル時最適化とゼロコスト抽象化を意識した `Result` 型のコア実装を見てほしい。余計なボイラープレートは一切削ぎ落としている。
import ‘package:meta/meta.dart’;
/// 成功と失敗を型安全に表現する封印された(sealed)代数的データ型
@immutable
sealed class Result
const Result();
/// 成功値を取り出す、またはデフォルト値を返す
T getOrElse(T defaultValue) => switch (this) {
Success(value: final v) => v,
Failure() => defaultValue,
};
}
/// 成功を表すバリアント
final class Success
const Success(this.value);
final T value;
@override
bool operator ==(Object other) =>
identical(this, other) || other is Success
@override
int get hashCode => value.hashCode;
}
/// 失敗を表すバリアント(エラー型Eを強制)
final class Failure
const Failure(this.error);
final E error;
@override
bool operator ==(Object other) =>
identical(this, other) || other is Failure
@override
int get hashCode => error.hashCode;
}
チーフアーキテクトの解説:なぜこの構造なのか?
- `sealed class`: 同一ファイル内でのみサブクラス化が許可される。これにより、Dart VMとAOTコンパイラ(dart2native)は、スイッチ文での網羅性(Exhaustiveness)を完全に静的解析できる。つまり、将来バリアントが増えたとき、対応漏れは100%コンパイルエラーになる。
- `@meta` と イミュータビリティ: 状態が途中で書き換わるバグを防ぐため、値はすべてイミュータブル。
—
実践:APIクライアントへの適用とパターンマッチング
では、この `Result` を実際の非同期API連携でどう使うのか。
ドメイン層のエラーを定義し、リポジトリ層からUI層に至るまでの美しいデータフローを見てみよう。
// 1. ドメイン固有のエラー定義(レコード型やsealedで詳細を表現可能)
sealed class ApiError {
const ApiError();
}
class NetworkError extends ApiError { const NetworkError(); }
class ServerError extends ApiError { const ServerError(this.code); final int code; }
class UnauthorizedError extends ApiError { const UnauthorizedError(); }
// 2. リポジトリ層:例外を絶対に外に投げず、Result型として返す
Future
try {
// 外部通信の模擬
if (userId.isEmpty) {
return const Failure(NetworkError());
}
if (userId == ‘401’) {
return const Failure(UnauthorizedError());
}
// 成功時はSuccessで包む
return Success(UserProfile(id: userId, name: ‘Dart Wizard’));
} catch (e) {
// 予期せぬシステム例外はここでキャッチし、ドメインエラーにマッピング
return const Failure(ServerError(500));
}
}
class UserProfile {
const UserProfile({required this.id, required this.name});
final String id;
final String name;
}
UI・プレゼンテーション層でのスイッチ式パターンマッチング
ここからがDart 3の真骨頂だ。UIのレンダリングや状態管理(BLoC / Riverpodなど)で、`switch` 式を用いたパターンマッチングにより、安全かつ宣言的にUIを構築する。
String renderUserScreen(Result
// Dart 3の switch 式による網羅的パターンマッチング
return switch (result) {
// 成功パターン
Success(value: final user) => ‘ようこそ、${user.name}さん!’,
// 失敗パターン(さらにエラーの種類で細かく分岐)
Failure(error: NetworkError()) => ‘ネットワーク接続を確認してください。’,
Failure(error: UnauthorizedError()) => ‘セッションが切れました。再ログインしてください。’,
Failure(error: ServerError(code: final c)) => ‘サーバーエラーが発生しました (Code: $c)’,
};
}
このコードの美しさは、「処理の漏れが絶対に起きない」という点にある。もし将来、`ApiError` に `MaintenanceError` を追加した場合、Dartのコンパイラは即座に赤い波線を出し、「すべてのケースが網羅されていません」と我々に警告を発する。
—
パフォーマンスとVMの最適化に関する知見
「こんなクラス階層を量産して、GC(ガベージコレクション)の負荷にならないのか?」
優秀なエンジニアならそう懸念するはずだ。Dart VMの内部構造を知る者として回答しよう。
1. アロケーションの最適化: `Success` や `Failure` はヒープにアロケートされるが、現代のDart VM(Generational GC)において、短命なオブジェクト(Young Generation)の生成・回収コストは極めて低い。
2. パターンマッチングのJIT/AOT最適化: Dart 3の `switch` 式は、単なる連続した `if-else` にコンパイルされるわけではない。コンパイラは効率的なジャンプテーブル(Jump Table)や型テストのインライン化を行い、実行時オーバーヘッドを最小限に抑え込む。
ただし、極限のパフォーマンスが求められるホットパス(毎フレーム呼ばれる描画ループなど)で頻繁にインスタンスを生成するのは避けるべきだ。通常のアプリケーション層、API境界、ドメインロジックにおいては、得られる「保守性」と「堅牢性」のメリットがコストを圧倒的に凌駕する。
—
コピペで使える!実務の現場で即導入する拡張テクニック
さらに実務で使いやすくするため、`Result` 型に `map` や `flatMap`(andThen)といった関数型プログラミングのユーティリティを生やしておこう。これがあると、非同期処理のチェインが劇的にエレガントになる。
extension ResultExt
bool get isSuccess => this is Success
bool get isFailure => this is Failure
/// 成功値の型を変換する (Functorのmap)
Result
return switch (this) {
Success(value: final v) => Success(transform(v)),
Failure(error: final e) => Failure(e),
};
}
/// 次の非同期Result処理へ繋げる (Monadのbind / flatMap)
Result
return switch (this) {
Success(value: final v) => transform(v),
Failure(error: final e) => Failure(e),
};
}
}
使用例:チェイン処理
void main() async {
final result = await fetchUserProfile(‘123’);
// mapを使ってUserProfileから名前だけを取り出すResultに変換
final nameResult = result.map((user) => user.name);
print(renderUserScreen(result));
}
—
まとめ:例外駆動開発からの脱却
例外駆動開発(Exception-Driven Development)は、コードの予測可能性を奪い、エラーハンドリングを「お祈りプログラミング」に変えてしまう。
Dart 3の `sealed class` とパターンマッチングを駆使した `Result型` は、エラーを「隠すべき異常」から「向き合うべきデータ」へと昇華させる。
明日のコードレビューで、誰かが `try-catch` で泥臭くエラーを処理しているのを見つけたら、このアーキテクチャを静かに提示してやるといい。
「おい、例外を投げるな。型で語れ」とね。