mcpField
E
error-handling-typed-results

error-handling-typed-results

github.com/zakariaf開発63151 回インストール5 週間前に更新
リポジトリ

error-handling-typed-results について

回復可能な失敗を型付き Result 値で表し、例外はバグや回復不能な状態に限定する Flutter・Dart 向けスキルです。アプリ各層のエラー処理を統一し、ユーザー入力データを保護するチームを対象としています。

主な機能

  1. 01sealed Result・Failure 型
  2. 02失敗バリアントの網羅的な処理
  3. 03境界での変換とローカルログ記録
  4. 04Isolate エラーの再ラップ
  5. 05トランザクション・下書き自動保存・復元可能な削除
  6. 06握りつぶした catch と削除整合性の検査

活用シーン

  1. 01想定内のリポジトリや I/O 失敗の処理
  2. 02型付き失敗コードから翻訳メッセージへの変換
  3. 03編集中のフォームデータの保持
  4. 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.dart
transactional_write.dart
typed_result_boundary.dart
references/
mechanism-selection.md
never-lose-data.md
result-failure-spine.md
scripts/
check-softdelete-parity.sh
check-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 を実行してください。

譲れないルール

  1. 回復可能な失敗は例外ではなく値としてモデル化します。 呼び出し元が処理する必要がある実行時の理由による失敗(DB エラー、ファイル/バックアップの I/O、通知のスケジュール設定、想定される未検出、無効な入力)はすべて、Result<T, F extends Failure>(sealed な Ok/Err)を返します。想定される失敗に対してレイヤーをまたいで例外をスローするコードは、レビューで却下します。
  2. 依存関係ゼロの sealed な Result と Failure を Flutter に依存しないレイヤーに手書きし、純粋なロジックとリポジトリで同じ語彙を共有します。 result_dart は、すぐに使える flatMap/mapError/AsyncResult が必要な場合に認められるドロップインです。全体を置き換えるか、手書きするかのどちらかにし、両方を混在させないでください。
  3. 境界ごとに sealed な Failure ファミリーを 1 つ定義し、各サブタイプには安定した code と型付きパラメーターを持たせます。ユーザー向けの文字列やローカライズ済み文字列は絶対に持たせません。 あらかじめ組み込まれたメッセージは、翻訳、RTL のミラーリング、数字の表示を損ないます。表示レイヤーで code からローカライズしてください。
  4. 失敗を switch で網羅的に扱い、default:/case _: は使いません。 sealed 型の網羅性チェックにより、「新しい失敗を追加した」場合、すべての switch で対応するまでコンパイルエラーになります。default: は、唯一のコンパイラーレベルの安全網を気付かれないまま無効にします。
  5. 境界で変換し、まずログを記録してから返します。 危険な呼び出しはそれぞれ 1 回だけラップし、狭い範囲を on 節で捕捉してから、型付きの失敗を返す前に、元のエラーとスタックをローカルログに記録します。 PlatformException/SqliteException/FormatException がアダプターを越えて Notifier やウィジェットに漏れることはありません。
  6. 握りつぶしてはいけません。 catch (_) {}、型やスタックを破棄する裸の catch (e)、throw e(rethrow を使ってください)は禁止です。CI でこれらを grep します。Error のサブタイプ(StateError、AssertionError)は捕捉しないでください。これらはバグなので、デバッグ時にはクラッシュさせます。
  7. 純粋なロジックを全域的に保ち、例外を決してスローさせません。 すべての純粋関数は、どのような入力に対しても値を返します。不確実性は明示的な出力(outOfRange バリアントや、クランプ済みの値)にします。プログラマーが保証すべき不変条件には assert を使います(リリース時には取り除かれます)。非同期処理とエラー処理は、純粋なコードの外側、境界部分に置きます。
  8. スローされたものはすべてバグまたは回復不能な状態であり、グローバルなハンドラーに送ります。回復可能な失敗にはしません。 回復可能な失敗は、レイヤーをまたいでスローされない型付きの Result 値なので、グローバルなハンドラーには到達しません。2 つのハンドラー(FlutterError.onError と PlatformDispatcher.instance.onError)およびそのインストール順序は app-startup-and-bootstrap が管理します。このスキルが管理するのは、ハンドラーに到達するものの分類だけです。クラッシュ SDK のためだけに runZonedGuarded を使う判断については references/mechanism-selection.md を参照してください。
  9. Isolate.run/compute のエラーは、呼び出し元で Result に再ラップします。 Isolate のエラーは FlutterError.onError に届きません。捕捉しない場合、不透明なまま伝播するため、待機する箇所で捕捉してください。
  10. ログはローカルにのみ記録します。 サイズ上限付きのローテーションファイルと、ユーザーが起動する「診断情報をエクスポート」機能を用意します。クラッシュ SDK を導入する場合は、独自のゾーンを使う明示的な判断としてください。それ以外では Crashlytics/Sentry/Firebase を使いません。
  11. 手入力データを決して失わないようにします。 複数テーブルを変更する場合は、変更全体を成功させるか、すべて取り消すかのいずれかにするため、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 を参照してください。

参考資料

コメント