mcpField
E
error-handling-typed-results

error-handling-typed-results

github.com/zakariaf开发63151 次安装5 周前更新
代码仓库

关于 error-handling-typed-results

面向 Flutter 与 Dart 的错误处理技能,将可恢复故障表示为带类型的 Result 值,仅将异常用于程序缺陷和不可恢复状态。适用于统一应用各层的错误处理并保护用户输入数据。

主要功能

  1. 01密封的 Result 与 Failure 类型
  2. 02穷尽处理所有故障变体
  3. 03在边界转换错误并记录本地日志
  4. 04包装 Isolate 错误
  5. 05事务、草稿自动保存与可撤销删除
  6. 06检查吞掉异常及软删除一致性

适用场景

  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

详细介绍

错误处理 — 类型化结果

可恢复的失败是带类型的值,在各层之间传递并进行穷尽式分支处理;只有真正的 Bug 和不可恢复状态才会抛出异常,并由全局兜底网统一捕获一次。无论哪种情况,都由一个永不丢失数据的子系统提供保护——事务、自动保存草稿、软删除/撤销。它是一等子系统,而非底层管道。只有两个层级,没有中间地带。

请根据当前任务阅读参考资料:

  • references/result-failure-spine.md — Result/Failure 源码、各边界的分类、在边界处转换、全局兜底网、隔离时重新包装以及本地日志记录。
  • references/mechanism-selection.md — 抛出异常与 assert、Exception、封闭式结果类型之间的选择、@useResult,以及 runZonedGuarded 的决策。
  • references/never-lose-data.md — 每次变更使用一个事务、防抖自动保存草稿,以及通过一个筛选器实现乐观软删除/回收站/撤销。

提交 PR 前,请运行 scripts/check-swallowed-catch.sh 和 scripts/check-softdelete-parity.sh。

不可妥协的规则

  1. 将可恢复的失败建模为值,而不是异常。 任何因运行时原因失败、且调用方必须处理的操作——数据库错误、文件/备份 I/O、通知调度、预期中的未找到、无效输入——都返回 Result<T, F extends Failure>(密封的 Ok/Err)。因预期中的失败跨层抛出异常,评审时会被拒绝。
  2. 在不依赖 Flutter 的层中手写一个零依赖的密封 Result 和 Failure, 让纯逻辑和仓储共用一套词汇。如果想使用现成的 flatMap/mapError/AsyncResult,可以采用获准的替代方案 result_dart——要么整体采用它,要么手写;绝不要混用两者。
  3. 每个边界使用一个密封的 Failure 系列,每个子类型都携带稳定的 code 和类型化参数——绝不能包含面向用户或本地化后的字符串。 内置消息会破坏翻译、RTL 镜像和数字渲染。在呈现层边界根据 code 进行本地化。
  4. 穷尽地 switch 处理失败,不要使用 default:/case _:。 密封类型的穷尽性检查会让“新增了一个失败类型”变成编译错误,直到每个 switch 都涵盖它为止;default: 会悄悄破坏你仅有的编译器级安全保障。
  5. 在边界转换——先记录日志,再返回。 对每个有风险的调用只包装一次,使用带 on 子句的窄范围捕获,在返回类型化失败之前,将原始错误及堆栈记录到本地日志中。PlatformException/SqliteException/FormatException 绝不能越过适配器泄漏到 Notifier 或小部件中。
  6. 绝不吞掉异常。 禁止使用 catch (_) {}、丢弃类型/堆栈的裸 catch (e),以及 throw e(请使用 rethrow)——CI 会搜索这些写法。绝不要捕获 Error 子类型(StateError、AssertionError):这些是程序错误,在调试模式下应让它们崩溃。
  7. 保持纯逻辑完整——它绝不抛出异常。 每个纯函数都必须对每种输入返回值;不确定性要作为显式输出(例如 outOfRange 变体或经过钳制的值)。程序员不变量使用 assert(发布版会移除)。异步/错误处理应放在纯代码之外,位于边界接缝处。
  8. 任何被抛出的异常都是程序错误或不可恢复状态,应交由全局兜底处理——绝不是可恢复失败。 可恢复失败是类型化的 Result 值,不会跨层抛出,因此也不会到达兜底处理器。两个处理器(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,这是需要单独 zone 的有意选择;否则不要使用 Crashlytics/Sentry/Firebase。
  11. 绝不丢失手动输入的数据。 每次多表变更使用一个 transaction(...)(全有或全无);将进行中的表单状态经防抖后持久化到 drafts 表;通过 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';
}

密封变体使用 final class、const 构造函数和 final 字段。你要按类型进行 switch;绝不会比较实例,因此不要在此处编写 ==/hashCode,也不要使用 freezed/equatable。

在边界转换

只包装一次有风险的调用。在返回无字符串的类型化失败之前,记录原始异常及堆栈。@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
  }
}

在调用处穷尽 switch

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,请在 widget 中切换处理失败,并将每个 code 映射到一条 l10n 消息——绝不使用 default:。

全局兜底只处理抛出的错误

app-startup-and-bootstrap 负责全局兜底及其所有相关事项:两个处理器——FlutterError.onError(build/layout/paint 同步错误)和 PlatformDispatcher.instance.onError(异步及平台通道错误)——在 runApp 之前安装,onError 返回 true,不使用 runZonedGuarded,并在记录日志前解包 ProviderException。此处不要重复说明 bootstrap()。

此技能负责定义哪些错误会到达这些处理器:仅限真正的 bug 和不可恢复的状态。每种可恢复的失败都是类型化的 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 或 widget。应在适配器中转换。
  • 因不确定性而在纯逻辑中使用 throw — 保持逻辑总是有定义;返回明确的“无法在预算范围内完成”值。
  • 为了“保险”而捕获 Error 子类型 — 这是在隐藏 bug;应让它在 debug 模式下崩溃。
  • 错误分支类型为 Exception/Object 的通用 Result<T> — 没有可供 switch 判断的类型;每种情况都会被静默丢弃。将错误类型设为 F extends Failure。
  • 对平台/插件返回值使用 assert — 发布版本会移除断言,因此真实设备上的失败会完全无声无息。请改为返回类型化失败。
  • 将通知/权限/插件错误视为同步错误 — 它们是异步 MethodChannel 错误;请在调用处捕获,并依赖 PlatformDispatcher.onError。
  • 未使用事务写入多张表 — 部分写入会悄无声息地破坏派生数据,离线时也无从察觉。
  • 绕过共享软删除筛选器执行第二次分析/图表查询 — 已删除的行会悄无声息地污染报告。每次读取都使用同一个筛选器。

完成标准

  • 可恢复的失败返回 sealed Result<T, F>;每个调用处都用 switch 处理所有情况,不使用 default:。
  • 每个边界都有一个 sealed Failure;每个子类型都包含稳定的 code 和类型化参数,不包含任何本地化字符串。
  • 纯逻辑始终有定义(只返回,不抛出);错误处理位于 repository/service/DAO 接缝处。
  • 边界捕获范围明确(使用 on 子句),先记录 (e, st),然后返回类型化 Err;不使用裸 catch 或空 catch;使用 rethrow 保留堆栈。
  • 只有 bug/不可恢复的状态会被抛出并交给全局兜底(由 app-startup-and-bootstrap 安装);可恢复的失败不会跨层抛出。
  • Isolate.run/compute 调用会在调用处将错误重新包装为 Result。
  • 多表变更在一个事务中执行;进行中的表单使用防抖自动保存;删除采用软删除且可撤销,并统一使用一个共享筛选器。
  • scripts/check-swallowed-catch.sh 和 scripts/check-softdelete-parity.sh 均通过。

相关技能

  • 有关箭头回调中的 Future 丢弃漏洞(没有 lint 能捕获)、unawaited/.timeout/mounted 的规范,以及订阅和计时器的释放,请参阅 async-safety。
  • 有关密封类、穷尽式 switch 表达式和完全不抛出异常的函数,请参阅 dart3-idioms-and-coding-standards。
  • 有关 AsyncNotifier ViewModel、AsyncValue 的 loading/error/data 状态,以及作为 DI 的 providers,请参阅 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 代码转换为错误状态、snackbar 或撤销操作(绝不渲染异常),请参阅 ui-states-and-feedback。
  • 有关永不丢失数据的用户侧实现:版本化备份和全有或全无的恢复,请参阅 data-export-and-restore。

参考资料

评论

支持 Markdown