例外駆動開発という「悪夢」からの脱却
コードレビューをしていて、次のようなコードに出くたことはないだろうか。
// どこかで定義された、中身の知れない非同期関数
Future
// ネットワークエラー、JSONパースエラー、権限エラー…何が飛ぶか分からない
var response = await dio.get(‘/users/$userId’);
return User.fromJson(response.data);
}
// 呼び出し側
try {
var user = await fetchUserData(‘123’);
print(user.name);
} catch (e) {
// とりあえずキャッチして握りつぶすか、ログを出して終了
print(‘エラー: $e’);
}
このコードの何が問題か。言語の仕様として例外(Exception)は便利に見えるが、「どの関数が何をスローするのか」が型シグネチャから完全に隠蔽されている点が、の大規模開発における最大のガンだ。呼び出し側はドキュメントを読むか、実装を隅々まで追いかけなければ、どんな爆弾が飛んでくるか予測できない。結果として、プロダクション環境で「予期せぬ `FormatException` や `StateError`」がアプリをクラッシュさせる。
Dart 3の登場により、我々ははこの構造的欠陥に終止符を打てるようになった。例外を投げさせず、エラーを「値(Value)」として型に閉じ込め、`switch` 式の網羅性チェック(Exhaustiveness Checking)によってハンドリングを強制する。
今回は、Dartコアの挙動を知り尽くしたアーキテクトの視点から、プロダクションで即座に使える `Result` 型を用いた堅牢なエラーハンドリングの標準化を伝授しよう。
—
1. Dart 3 パターンマッチングと `Result` 型の核心
まず、なぜ `try-catch` ではなく `Result` 型なのか。
Dart VMの視点から言えば、例外機構(Stack Traceの生成や巻き戻し)はランタイムに小さくないコストを強いる。しかし、何よりも問題なのは「型の安全性がコンパイル時に保証されない」ことだ。
そこで、関数の成功と失敗を次のような代数データ型(ADT: Algebraic Data Types)として表現する。
// Result型:成功(Success)か失敗(Failure)の二者択一を強制する
sealed class Result
const Result();
}
class Success
final T data;
const Success(this.data);
}
class Failure
final E error;
const Failure(this.error);
}
ここで `sealed` 修飾子を使っている点が極めて重要だ。`sealed` クラスを継承したサブクラスは、同一ライブラリ内でのみ定義可能であり、コンパイラ(AOTコンパイラ / JITコンパイラ)はそのすべての派生型を完全に把握できる。
これが、Dart 3の `switch` 式における網羅性チェック(Exhaustiveness Checking)の土台となる。
—
2. プロダクションコード:型安全なAPIクライアントの設計
では、実際のフロントエンド開発やAPI連携を想定した、実用的なコードを見ていこう。エラーの種類(NetworkError, ValidationError, ServerError)もすべて `sealed class` で定義し、ドメインのエラーを完全に型に落とし込む。
以下のコードは、そのままコピーしてプロジェクトの基盤として使える品質に仕上げている。
import ‘dart:convert’;
import ‘package:http/http.dart’ as http;
// ==========================================
// 1. ドメインエラーの定義
// ==========================================
sealed class AppError {
const AppError();
String get message;
}
class NetworkError extends AppError {
final String details;
const NetworkError(this.details);
@override
String get message => ‘ネットワーク接続に失敗しました: $details’;
}
class ApiError extends AppError {
final int statusCode;
const ApiError(this.statusCode);
@override
String get message => ‘サーバーエラーが発生しました (Status: $statusCode)’;
}
class ParseError extends AppError {
final String details;
const ParseError(this.details);
@override
String get message => ‘データ構造のパースに失敗しました: $details’;
}
// ==========================================
// 2. Result 型の定義
// ==========================================
sealed class Result
const Result();
// 成功値を取り出す(失敗時はデフォルト値を返すなど)
bool get isSuccess => this is Success
bool get isFailure => this is Failure
}
class Success
final T data;
const Success(this.data);
}
class Failure
final E error;
const Failure(this.error);
}
// ==========================================
// 3. データモデル
// ==========================================
class User {
final int id;
final String name;
User({required this.id, required this.name});
factory User.fromJson(Map
return User(
id: json[‘id’] as int,
name: json[‘name’] as String,
);
}
}
// ==========================================
// 4. APIリポジトリ(例外をスローせず Result を返す)
// ==========================================
Future
try {
final url = Uri.parse(‘https://jsonplaceholder.typicode.com/users/$userId’);
final response = await http.get(url).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
return Failure(ApiError(response.statusCode));
}
try {
final jsonMap = jsonDecode(response.body) as Map
final user = User.fromJson(jsonMap);
return Success(user);
} catch (e) {
return ParseError(e.toString()) as Result
}
} catch (e) {
// タイムアウトやネットワーク切断など
return Failure(NetworkError(e.toString()));
}
}
—
3. switch式による「処理の強制」と美学
さて、上記で定義した `fetchUser` をUI層やコンポーネント設計の中でどう呼び出すか。
ここが本稿のハイライトである。Dart 3の `switch` 式 を用いることで、エラーハンドリングの書き忘れをコンパイルエラーとして完全に封じ込める。
Future
print(‘ユーザーID: $userId のデータを取得中…’);
final result = await fetchUser(userId);
// switch式によるパターンマッチング
// ※もしここで Failure のハンドリングを書き忘れると、コンパイルエラーになる!
final displayMessage = switch (result) {
Success(data: final user) => ‘ようこそ、${user.name}さん!’,
Failure(error: final err) => ‘【エラー】 ${err.message}’,
};
print(displayMessage);
}
void main() async {
// 正常系テスト
await renderUserProfile(1);
// 異常系テスト(存在しないID)
await renderUserProfile(99999);
}
なぜこれが強力なのか?
1. 網羅性チェック(Exhaustiveness Checking)の恩恵
もし将来的に `AppError` に新しい派生クラス(例: `MaintenanceError`)を追加したとする。その瞬間、Dartコンパイラは `switch (result)` の箇所で「網羅されていないケースがある」と指摘し、ビルドを止めてくれる。開発者がエラーハンドリングの分岐を追加し忘れるバグは、この時点で物理的に不可能になる。
2. 三項演算子や `if-else` の地獄からの解放
従来の `if (result.isSuccess)` のようなコードは、ダウンキャストやnullableな値の扱いに悩まされがちだった。`switch` 式のパターンマッチングは、スコープ内で型安全にプロパティ(`data` や `error`)をバインド(Destructuring: 分割代入)してくれるため、コードが圧倒的にクリーンになる。
—
4. パフォーマンスとメモリレイアウトの最適化に関する知見
ここで、Dartコアコミッターの視点からパフォーマンスに関する深い話をしよう。
「`Result` のようなオブジェクトを毎回生成すると、GC(ガベージコレクション)の負荷が上がるのではないか?」という懸念を持つエンジニアは、極めて優秀だ。実際、高頻度で呼ばれるループ内や、60fps / 120fpsを維持すべきFlutterのフレーム構築処理の中で無駄にオブジェクトをallocするのは避けるべきだ。
しかし、現代のDart VM(特にJITの最適化やAOTの型推論)において、以下の設計を守ることでオーバーヘッドを最小化できる。
1. `const` コンストラクタの徹底
上記のコードで `const Result();` や `const Success(this.data);` と定義している点に注目してほしい。不変(Immutable)なクラスで `const` を付与すると、Dart VMはそれらを定数プールにキャッシュし、実質的なメモリ割り当てコストをゼロに近づけることができる。
2. ジェネリクスの特殊化(Specialization)
DartのAOTコンパイラは、型引数 `T` が具体化された際に関数やクラスのコードを特殊化する。これにより、ボクシング(Boxed values: プリミティブ型をオブジェクトとしてラップすること)のコストが最適化される。
もし極限のパフォーマンスが要求されるホットパス(Hot Path)であれば、例外的に生の値やコードを返す設計にすることもある。しかし、UIとAPIを行き交う一般的なビジネスロジックや非同期処理においては、「GCのコスト」よりも「バグの混入コストおよび保守性」の方が圧倒的に高い。型の安全性という強固な礎を得るトレードオフとして、`Result` 型の導入コストは無視できるほど小さい。
—
5. まとめ
例外(Exceptions)は「見えない大域脱出(Non-local return)」であり、コードの予測可能性を破壊する。
Dart 3の `sealed` クラスと `switch` 式のコンビネーションは、RustやSwiftといったモダン言語が到達している「型によるエラーハンドリングの標準化」をDartの世界にもたらした。
- 例外を投げず、`Result
` を返す設計にシフトする。 - 処理分岐には `if-else` ではなく、網羅性が保証された `switch` 式を使う。
- `const` コンストラクタを駆使し、パフォーマンスの劣化を防ぐ。
このパターンをチームのコーディング規約として定着させれば、あなたのプロジェクトから「原因不明のクラッシュ」や「エラーハンドリングの抜け漏れ」は完全に駆逐されるだろう。さあ、今日のコードレビューから、この美しい設計を取り入れてみてほしい。