error-handling-typed-results について
回復可能な失敗を型付き Result 値で表し、例外はバグや回復不能な状態に限定する Flutter・Dart 向けスキルです。アプリ各層のエラー処理を統一し、ユーザー入力データを保護するチームを対象としています。
主な機能
- 01sealed Result・Failure 型
- 02失敗バリアントの網羅的な処理
- 03境界での変換とローカルログ記録
- 04Isolate エラーの再ラップ
- 05トランザクション・下書き自動保存・復元可能な削除
- 06握りつぶした catch と削除整合性の検査
活用シーン
- 01想定内のリポジトリや I/O 失敗の処理
- 02型付き失敗コードから翻訳メッセージへの変換
- 03編集中のフォームデータの保持
- 04ソフト削除フローへの取り消し機能追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill error-handling-typed-resultsインストール後、Claude Code で/error-handling-typed-resultsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/soft_delete_undo.darttransactional_write.darttyped_result_boundary.dartreferences/mechanism-selection.mdnever-lose-data.mdresult-failure-spine.mdscripts/check-softdelete-parity.shcheck-swallowed-catch.sh詳細
エラーハンドリング — 型付き結果
復旧可能な失敗は型付きの値としてレイヤー間を流れ、網羅的に切り替えて処理します。本物のバグや復旧不可能な状態だけをスローし、それらはグローバルな受け皿で一度だけ捕捉します。両者を包み込む、データを決して失わない仕組み(トランザクション、自動保存ドラフト、ソフトデリート/Undo)は、単なる配管ではなく、第一級のサブシステムです。階層は2つだけで、中間はありません。
作業に応じて、次のリファレンスを確認してください。
references/result-failure-spine.md—Result/Failureの根幹、境界ごとの分類、境界での変換、グローバルな受け皿、分離時の再ラップ、ローカルロギングについて説明しています。references/mechanism-selection.md— throw、assert、Exception、シールドされた結果型の使い分け、@useResult、runZonedGuardedを選択する判断について説明しています。references/never-lose-data.md— 変更ごとに1つのトランザクション、デバウンスされた自動保存ドラフト、単一のフィルターを介した楽観的ソフトデリート/ゴミ箱/Undoについて説明しています。
PRを作成する前に、scripts/check-swallowed-catch.sh と scripts/check-softdelete-parity.sh を実行してください。
譲れないルール
- 回復可能な失敗は例外ではなく値としてモデル化します。 呼び出し元が処理する必要がある実行時の理由による失敗(DB エラー、ファイル/バックアップの I/O、通知のスケジュール設定、想定される未検出、無効な入力)はすべて、
Result<T, F extends Failure>(sealed なOk/Err)を返します。想定される失敗に対してレイヤーをまたいで例外をスローするコードは、レビューで却下します。 - 依存関係ゼロの sealed な
ResultとFailureを Flutter に依存しないレイヤーに手書きし、純粋なロジックとリポジトリで同じ語彙を共有します。result_dartは、すぐに使えるflatMap/mapError/AsyncResultが必要な場合に認められるドロップインです。全体を置き換えるか、手書きするかのどちらかにし、両方を混在させないでください。 - 境界ごとに sealed な
Failureファミリーを 1 つ定義し、各サブタイプには安定したcodeと型付きパラメーターを持たせます。ユーザー向けの文字列やローカライズ済み文字列は絶対に持たせません。 あらかじめ組み込まれたメッセージは、翻訳、RTL のミラーリング、数字の表示を損ないます。表示レイヤーでcodeからローカライズしてください。 - 失敗を
switchで網羅的に扱い、default:/case _:は使いません。 sealed 型の網羅性チェックにより、「新しい失敗を追加した」場合、すべてのswitchで対応するまでコンパイルエラーになります。default:は、唯一のコンパイラーレベルの安全網を気付かれないまま無効にします。 - 境界で変換し、まずログを記録してから返します。 危険な呼び出しはそれぞれ 1 回だけラップし、狭い範囲を
on節で捕捉してから、型付きの失敗を返す前に、元のエラーとスタックをローカルログに記録します。PlatformException/SqliteException/FormatExceptionがアダプターを越えて Notifier やウィジェットに漏れることはありません。 - 握りつぶしてはいけません。
catch (_) {}、型やスタックを破棄する裸のcatch (e)、throw e(rethrowを使ってください)は禁止です。CI でこれらを grep します。Errorのサブタイプ(StateError、AssertionError)は捕捉しないでください。これらはバグなので、デバッグ時にはクラッシュさせます。 - 純粋なロジックを全域的に保ち、例外を決してスローさせません。 すべての純粋関数は、どのような入力に対しても値を返します。不確実性は明示的な出力(
outOfRangeバリアントや、クランプ済みの値)にします。プログラマーが保証すべき不変条件にはassertを使います(リリース時には取り除かれます)。非同期処理とエラー処理は、純粋なコードの外側、境界部分に置きます。 - スローされたものはすべてバグまたは回復不能な状態であり、グローバルなハンドラーに送ります。回復可能な失敗にはしません。 回復可能な失敗は、レイヤーをまたいでスローされない型付きの
Result値なので、グローバルなハンドラーには到達しません。2 つのハンドラー(FlutterError.onErrorとPlatformDispatcher.instance.onError)およびそのインストール順序はapp-startup-and-bootstrapが管理します。このスキルが管理するのは、ハンドラーに到達するものの分類だけです。クラッシュ SDK のためだけにrunZonedGuardedを使う判断についてはreferences/mechanism-selection.mdを参照してください。 Isolate.run/computeのエラーは、呼び出し元でResultに再ラップします。 Isolate のエラーはFlutterError.onErrorに届きません。捕捉しない場合、不透明なまま伝播するため、待機する箇所で捕捉してください。- ログはローカルにのみ記録します。 サイズ上限付きのローテーションファイルと、ユーザーが起動する「診断情報をエクスポート」機能を用意します。クラッシュ SDK を導入する場合は、独自のゾーンを使う明示的な判断としてください。それ以外では Crashlytics/Sentry/Firebase を使いません。
- 手入力データを決して失わないようにします。 複数テーブルを変更する場合は、変更全体を成功させるか、すべて取り消すかのいずれかにするため、
transaction(...)を 1 回使います。入力中のフォーム状態は、デバウンス処理を介してdraftsテーブルに保存します。削除には、共通のフィルターを 1 つ通してis_deletedによる論理削除を使い、SnackBar の「元に戻す」機能を設けます。詳しくはreferences/never-lose-data.mdを参照してください。
Result と Failure の基盤
ネイティブの Dart 3 sealed を使い、依存関係はゼロです。純粋なロジックとリポジトリは、同じ型を返します。
// lib/core/result.dart — Flutter-free.
sealed class Result<T, F extends Failure> {
const Result();
}
final class Ok<T, F extends Failure> extends Result<T, F> {
const Ok(this.value);
final T value;
}
final class Err<T, F extends Failure> extends Result<T, F> {
const Err(this.failure);
final F failure;
}
extension ResultX<T, F extends Failure> on Result<T, F> {
R fold<R>(R Function(T) onOk, R Function(F) onErr) => switch (this) {
Ok(:final value) => onOk(value),
Err(:final failure) => onErr(failure),
};
Result<R, F> map<R>(R Function(T) f) => switch (this) {
Ok(:final value) => Ok(f(value)),
Err(:final failure) => Err(failure),
};
}
// lib/core/failure.dart — one family per boundary; code + typed params, no strings.
sealed class Failure {
const Failure();
String get code; // stable, localization-key-like, e.g. 'db.not_found'
}
sealed class OrderFailure extends Failure {
const OrderFailure();
}
final class OrderNotFound extends OrderFailure {
const OrderNotFound(this.id);
final String id;
@override
String get code => 'order.not_found';
}
final class OrderConstraintViolated extends OrderFailure {
const OrderConstraintViolated(this.field);
final String field;
@override
String get code => 'order.constraint_violated';
}
final class OrderStoreUnavailable extends OrderFailure {
const OrderStoreUnavailable();
@override
String get code => 'order.store_unavailable';
}
sealed バリアントには、final class、const コンストラクター、final フィールドを使います。型に基づいて switch するため、インスタンスを比較することはありません。ここでは ==/hashCode を記述したり、freezed/equatable を使ったりしないでください。
境界で変換する
危険な呼び出しは 1 回だけラップします。型付きで文字列を含まない失敗を返す前に、元の例外とスタックをログに記録します。@useResult により、結果の破棄は lint エラーになります。
// lib/data/order_repository.dart — the ONLY place the store SDK is imported.
@useResult
Future<Result<Order, OrderFailure>> findOrder(String id) async {
try {
final row = await _dao.byId(id).timeout(const Duration(seconds: 5));
if (row == null) return Err(OrderNotFound(id));
return Ok(row.toDomain());
} on TimeoutException catch (e, st) {
_log.error('order.find.timeout', e, st); // log FIRST — never swallow
return const Err(OrderStoreUnavailable());
} on SqliteException catch (e, st) {
_log.error('order.find.db', e, st);
return Err(_mapDbException(e)); // typed, stable code, no strings
}
}
呼び出し元で網羅的に分岐する
Notifier(ViewModel)ですべてのケースを切り替え、ここでcodeからローカライズします。
// lib/features/order/order_notifier.dart — manual Riverpod 3.x AsyncNotifier
// ViewModel. Hand-written providers are the default; `@riverpod` codegen is an
// optional alternative (see state-management-riverpod). `autoDispose`/`family`
// are provider MODIFIERS — never AutoDispose*/*Family base classes, which were
// removed in 3.0. The family arg (the order id) is read via `arg`.
final orderNotifierProvider =
AsyncNotifierProvider.autoDispose.family<OrderNotifier, Order, String>(
OrderNotifier.new);
class OrderNotifier extends AsyncNotifier<Order> {
@override
Future<Order> build() async {
switch (await ref.watch(orderRepositoryProvider).findOrder(arg)) {
case Ok(:final value):
return value;
case Err(:final failure): // sealed => no default:; adding a case is a compile error
throw OrderLoadException(failure); // surfaces as AsyncValue.error to the View
}
}
}
// A View renders AsyncValue loading/error/data; it maps failure.code to a localized string.
汎用エラーを表示するのではなく、ケースごとに復旧するUIでは、ウィジェット内で失敗を切り替え、各codeをl10nメッセージにマッピングします。default:は決して使わないでください。
グローバルな受け皿が捕捉するのはスローされたバグのみ
app-startup-and-bootstrapが受け皿とそのすべてを担当します。つまり、runAppの前に設定する2つのハンドラー、FlutterError.onError(build/layout/paintの同期エラー)とPlatformDispatcher.instance.onError(非同期エラーとプラットフォームチャネルのエラー)、onErrorがtrueを返すこと、runZonedGuardedを使わないこと、ログ出力前にProviderExceptionのラップを解除することです。ここではbootstrap()について繰り返し説明しないでください。
このスキルが担当するのは、それらのハンドラーに届くものの分類だけです。本物のバグと回復不能な状態のみです。回復可能な失敗はすべて、レイヤーを越えてスローされることのない型付きのResult/Failure値として扱うため、受け皿には届きません。Isolateエラーは、受け皿が捕捉できない唯一のスローエラーのカテゴリです。呼び出し元でResultにラップし直してください(下記)。
Isolateエラーの再ラップ
// Heavy work runs off the UI isolate; its errors do NOT reach FlutterError.onError.
Future<Result<Report, ComputeFailure>> buildReport(ReportInput input) async {
try {
return Ok(await Isolate.run(() => ReportBuilder().run(input)));
} on Object catch (e, st) {
_log.error('report.compute', e, st);
return const Err(ComputeFailed('report'));
}
}
アンチパターン
catch (_) {}/単独のcatch (e)— 型とスタックを破棄します。失われたデータを再取得できない場合は致命的です。対象を絞って捕捉し、(e, st)をログに記録してください。Failure/Exception内のユーザー向けString— 翻訳、RTLのミラーリング、数字の表示に支障をきたします。コードと型付きパラメーターのみを使用してください。- sealed switchでの
default:/case _:— 新しい失敗サブタイプが未処理のまま通過し、コンパイル時の網羅性チェックが失われます。 - catch内の
throw e;— スタックが再スローした行から始まるものにリセットされます。rethrowを使ってください。 - UIにおける低レベル例外 —
PlatformException/SqliteException/FormatExceptionをNotifierやウィジェットに到達させないでください。アダプターで変換してください。 - 不確実性に対する純粋ロジック内の
throw— 全入力に対して定義される処理にし、明示的な「予算内では実行できない」値を返してください。 - 「念のため」に
Errorサブタイプを捕捉すること — バグを隠すことになります。デバッグ時にはクラッシュさせてください。 - エラー側が
Exception/Objectの汎用Result<T>— 切り替え可能な型がなく、すべてのケースが黙って破棄されます。エラーをF extends Failureとして型付けしてください。 - プラットフォーム/プラグインの戻り値に対する
assert— リリースビルドではassertが取り除かれるため、実機での失敗が完全に無音になります。代わりに型付きの失敗を返してください。 - 通知/権限/プラグインのエラーを同期エラーとして扱うこと — これらは非同期のMethodChannelエラーです。呼び出し元で捕捉し、さらに
PlatformDispatcher.onErrorにも依存してください。 - トランザクションを使わない複数テーブルへの書き込み — 書き込みが中途半端に適用され、オフラインでは気付く方法がないまま派生データが破損します。
- 共有のソフトデリートフィルターを迂回する2つ目の分析/グラフ用クエリ — 削除済みの行がレポートに混入します。すべての読み取りで1つのフィルターを使ってください。
完了の定義
- 回復可能な失敗はsealed
Result<T, F>を返し、すべての呼び出し元でdefault:なしに全ケースをswitchします。 - 各境界が1つのsealed
Failureを持ち、各サブタイプが安定したcodeと型付きパラメーターを持ちます。ローカライズ済み文字列は含めません。 - 純粋ロジックは全入力に対して定義され(戻り値を返し、スローしない)、エラー処理はrepository/service/DAOの境界に置かれています。
- 境界での捕捉対象を絞り(
on句を使用)、まず(e, st)をログに記録してから、型付きのErrを返します。単独のcatchや空のcatchは使わず、rethrowでスタックを保持します。 - グローバルな受け皿(
app-startup-and-bootstrapが設定)が捕捉するのはバグ/回復不能な状態のみです。回復可能な失敗をレイヤー越しにスローしません。 -
Isolate.run/computeの呼び出しは、呼び出し元でエラーをResultにラップし直します。 - 複数テーブルへの変更は1つのトランザクション内で実行され、入力中のフォームはデバウンスを使って自動保存され、削除は1つの共有フィルターのもとで論理削除され、復元可能です。
-
scripts/check-swallowed-catch.shとscripts/check-softdelete-parity.shが成功します。
関連スキル
- 矢印コールバックで Future が破棄される問題を lint で検出できないこと、
unawaited/.timeout/mountedの取り扱い、サブスクリプションやタイマーの破棄については、async-safetyを参照してください。 - sealed クラス、網羅的な
switch式、例外をスローしない全域関数については、dart3-idioms-and-coding-standardsを参照してください。 - AsyncNotifier ViewModel、
AsyncValueの loading/error/data、DI としてのプロバイダーについては、state-management-riverpodを参照してください。 main()の順序とグローバルエラーハンドラーを設置する場所については、app-startup-and-bootstrapを参照してください。- DAO/トランザクションの仕組みについては
persistence-driftを、スキーマの進化についてはrun-migrationを参照してください。 - SDK/プラグインの例外を捕捉して変換する、注入可能なインターフェースの境界については、
service-boundary-and-nativeを参照してください。 - 失敗の
codeをローカライズされたメッセージに対応付ける方法と、解析前に数字を正規化する方法(ここでは重複して記述しないでください)については、i18n-rtl-l10nを参照してください。 Err分岐のアサーションや、fake_asyncを使ったクロック駆動のデバウンス/削除テストについては、testing-strategyを参照してください。avoid_catches_without_on_clauses、unawaited_futures、unused_resultをエラーに昇格する方法については、lint-and-style-configを参照してください。Failureコードをエラー状態、スナックバー、または Undo に変換し、例外を描画しない方法については、ui-states-and-feedbackを参照してください。- データを絶対に失わないためのユーザー向け機能である、バージョン管理されたバックアップと一括処理の復元については、
data-export-and-restoreを参照してください。
参考資料
- Dart — エラー処理(例外とエラー、
rethrow、catch の具体性) - Dart — 分岐:網羅的な switch と クラス修飾子:
sealed - Dart — 並行処理(
Isolate.run、メッセージパッシング) - Flutter — Flutter でのエラー処理(
FlutterError.onError、PlatformDispatcher.instance.onError) - Riverpod — AsyncNotifier / AsyncValue
- pub.dev — result_dart · clock · meta (
@useResult)