关于 error-handling-typed-results
面向 Flutter 与 Dart 的错误处理技能,将可恢复故障表示为带类型的 Result 值,仅将异常用于程序缺陷和不可恢复状态。适用于统一应用各层的错误处理并保护用户输入数据。
主要功能
- 01密封的 Result 与 Failure 类型
- 02穷尽处理所有故障变体
- 03在边界转换错误并记录本地日志
- 04包装 Isolate 错误
- 05事务、草稿自动保存与可撤销删除
- 06检查吞掉异常及软删除一致性
适用场景
- 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详细介绍
错误处理 — 类型化结果
可恢复的失败是带类型的值,在各层之间传递并进行穷尽式分支处理;只有真正的 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。
不可妥协的规则
- 将可恢复的失败建模为值,而不是异常。 任何因运行时原因失败、且调用方必须处理的操作——数据库错误、文件/备份 I/O、通知调度、预期中的未找到、无效输入——都返回
Result<T, F extends Failure>(密封的Ok/Err)。因预期中的失败跨层抛出异常,评审时会被拒绝。 - 在不依赖 Flutter 的层中手写一个零依赖的密封
Result和Failure, 让纯逻辑和仓储共用一套词汇。如果想使用现成的flatMap/mapError/AsyncResult,可以采用获准的替代方案result_dart——要么整体采用它,要么手写;绝不要混用两者。 - 每个边界使用一个密封的
Failure系列,每个子类型都携带稳定的code和类型化参数——绝不能包含面向用户或本地化后的字符串。 内置消息会破坏翻译、RTL 镜像和数字渲染。在呈现层边界根据code进行本地化。 - 穷尽地
switch处理失败,不要使用default:/case _:。 密封类型的穷尽性检查会让“新增了一个失败类型”变成编译错误,直到每个 switch 都涵盖它为止;default:会悄悄破坏你仅有的编译器级安全保障。 - 在边界转换——先记录日志,再返回。 对每个有风险的调用只包装一次,使用带
on子句的窄范围捕获,在返回类型化失败之前,将原始错误及堆栈记录到本地日志中。PlatformException/SqliteException/FormatException绝不能越过适配器泄漏到 Notifier 或小部件中。 - 绝不吞掉异常。 禁止使用
catch (_) {}、丢弃类型/堆栈的裸catch (e),以及throw e(请使用rethrow)——CI 会搜索这些写法。绝不要捕获Error子类型(StateError、AssertionError):这些是程序错误,在调试模式下应让它们崩溃。 - 保持纯逻辑完整——它绝不抛出异常。 每个纯函数都必须对每种输入返回值;不确定性要作为显式输出(例如
outOfRange变体或经过钳制的值)。程序员不变量使用assert(发布版会移除)。异步/错误处理应放在纯代码之外,位于边界接缝处。 - 任何被抛出的异常都是程序错误或不可恢复状态,应交由全局兜底处理——绝不是可恢复失败。 可恢复失败是类型化的
Result值,不会跨层抛出,因此也不会到达兜底处理器。两个处理器(FlutterError.onError+PlatformDispatcher.instance.onError)及其安装顺序由app-startup-and-bootstrap负责;此技能只负责界定哪些内容会到达它们。仅为崩溃 SDK 使用runZonedGuarded的决策见references/mechanism-selection.md。 - 在调用处将
Isolate.run/compute错误重新包装为Result。 Isolate 错误不会触发FlutterError.onError;应在等待它们的位置捕获,否则错误会以不透明的方式传播。 - 只在本地记录日志。 使用有大小上限的轮转文件,并提供由用户主动触发的“导出诊断信息”入口。如果要接入崩溃 SDK,这是需要单独 zone 的有意选择;否则不要使用 Crashlytics/Sentry/Firebase。
- 绝不丢失手动输入的数据。 每次多表变更使用一个
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。
参考资料
- Dart — 错误处理(异常与错误、
rethrow、捕获的具体性) - Dart — 分支:穷尽式 switch 和 类修饰符:
sealed - Dart — 并发(
Isolate.run、消息传递) - Flutter — 在 Flutter 中处理错误(
FlutterError.onError、PlatformDispatcher.instance.onError) - Riverpod — AsyncNotifier / AsyncValue
- pub.dev — result_dart · clock · meta(
@useResult)